typescript 131 lines · 3 tabs

Typed In-Memory Job Queue With a Concurrency-Limited Worker Pool

Shared by codesnips Jul 2026
3 tabs
import { EventEmitter } from "events";

export interface Job<T> {
  id: string;
  payload: T;
  attempts: number;
}

export class JobQueue<T> extends EventEmitter {
  private items: Job<T>[] = [];
  private seq = 0;

  enqueue(payload: T, attempts = 0): Job<T> {
    const job: Job<T> = { id: `job_${++this.seq}`, payload, attempts };
    this.items.push(job);
    this.emit("enqueued");
    return job;
  }

  dequeue(): Job<T> | undefined {
    return this.items.shift();
  }

  get size(): number {
    return this.items.length;
  }

  waitForJob(signal: () => boolean): Promise<void> {
    if (this.size > 0 || !signal()) return Promise.resolve();
    return new Promise((resolve) => {
      const onEvent = () => {
        this.off("enqueued", onEvent);
        this.off("wake", onEvent);
        resolve();
      };
      this.once("enqueued", onEvent);
      this.once("wake", onEvent);
    });
  }

  wake(): void {
    this.emit("wake");
  }
}
3 files · typescript Explain with highlit

This snippet builds a small, fully typed job queue with a worker pool that drains it while respecting a concurrency limit. The generic type parameter is the key idea: instead of a queue that stores any payloads, JobQueue<T> in queue.ts carries the payload shape through every method, so enqueue, the internal Job<T> record, and the handler in worker.ts all agree at compile time. This catches an entire class of bugs where a producer pushes one shape and a consumer expects another.

In queue.ts the queue is a plain FIFO backed by an array, but each entry is wrapped in a Job<T> that tracks id, attempts, and payload. The queue extends EventEmitter so the worker can react to enqueued events without polling; waitForJob returns a promise that resolves either immediately if work is pending or on the next enqueued signal. That push model is what lets an idle worker sleep at zero CPU cost instead of busy-looping.

The real work happens in Worker in worker.ts. It spins up concurrency independent runLoop coroutines that each pull one job, run the user handler, and loop again. Because there are exactly N loops, at most N jobs run at once — the array of loops is the concurrency limit, so no semaphore or counter is needed. Promise.all over the loops means start resolves only when every loop has exited.

Retries are handled by re-enqueuing: on a thrown handler, attempts is compared against maxRetries, and the job is either pushed back with an incremented count or routed to onFailed. This keeps failures durable within the run rather than swallowing them.

The stop method flips a running flag and wakes any parked loops so they can observe the shutdown and drain cleanly instead of hanging on waitForJob.

main.ts wires it together: a typed EmailJob payload, an async handler, and four concurrent workers. The trade-off to note is that this queue is in-memory and single-process — restarts lose jobs — so it fits background work within one Node instance, not cross-service durability, where a real broker like Redis or SQS belongs.


Related snips

Share this code

Here's the card — post it anywhere.

Typed In-Memory Job Queue With a Concurrency-Limited Worker Pool — share card
Link copied