Build an In-Process Job Queue with Workers and Retries
A durable background job queue that persists tasks to disk, runs them through a bounded worker pool, retries failures with backoff, and exposes a CLI to enqueue jobs and inspect the queue.
Project goal
Build a job queue that accepts named jobs, persists them to disk so they survive a restart, executes them through a configurable worker pool, applies retry with exponential backoff, and reports queue state from the command line.
Final features
- Enqueue jobs by name with a JSON payload and get a stable id back.
- Execute jobs through a bounded pool with configurable concurrency.
- Retry failed jobs up to a limit with exponential backoff and jitter.
- Persist queue state to disk so pending jobs resume after a restart.
- Inspect counts of pending, running, done, and failed jobs via the CLI.
Architecture
A queue store serialises jobs to a JSON file through the same atomic-write discipline a database would use; a scheduler pulls due jobs in priority order; a worker pool runs handlers concurrently up to a limit; and a retry policy moves failures back to the queue with a computed delay. Handlers are registered by name so the queue core never imports job logic.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with promises and async control flow
- Basic understanding of filesystem persistence
Dependencies
- node >=20 — Timers, file system persistence, and worker pool
- vitest ^2.1.0 — Deterministic tests with an injected clock
Directory structure
job-queue/
├── src/
│ ├── store.js
│ ├── queue.js
│ ├── pool.js
│ ├── retry.js
│ └── cli.js
├── jobs/
│ └── handlers.js
├── test/
│ ├── queue.test.js
│ └── retry.test.js
└── package.json
Phases
1. Persistent job store
Implement a JSON store that serialises jobs with atomic writes and supports querying the next due job.
Files: src/store.js
import { readFile, writeFile, rename } from 'node:fs/promises';
export function createFileStore(path) {
let jobs = [];
let loaded = false;
async function ensure() {
if (loaded) return;
try { jobs = JSON.parse(await readFile(path, 'utf8')); }
catch (err) { if (err.code !== 'ENOENT') throw err; jobs = []; }
loaded = true;
}
async function flush() {
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(jobs, null, 2));
await rename(tmp, path);
}
return {
async add(job) { await ensure(); jobs.push(job); await flush(); },
async nextDue(at) {
await ensure();
const due = jobs.filter((j) => j.status === 'pending' && j.runAt <= at);
due.sort((a, b) => a.runAt - b.runAt);
return due[0] ?? null;
},
async update(id, patch) {
const i = jobs.findIndex((j) => j.id === id);
if (i >= 0) jobs[i] = { ...jobs[i], ...patch };
await flush();
},
};
}Expected behavior: Two writes followed by a reload both persist, the file always parses, and nextDue returns only jobs whose runAt is in the past.
2. Retry policy
Compute the next attempt time with exponential backoff and a cap, and decide when a job has exhausted its attempts.
Files: src/retry.js
export function backoffDelay(attempt, { base = 100, cap = 30000, jitter = true } = {}) {
const raw = Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
if (!jitter) return raw;
return Math.round(raw / 2 + Math.random() * (raw / 2));
}
export function planRetry(job, now) {
const attempts = job.attempts + 1;
if (attempts >= job.maxAttempts) {
return { ...job, attempts, status: 'failed' };
}
return { ...job, attempts, status: 'pending', runAt: now + backoffDelay(attempts) };
}Expected behavior: A job with maxAttempts 3 and no jitter produces delays 100, 200, then fails on the third attempt, all asserted against an injected clock.
3. Bounded worker pool
Run due jobs concurrently up to a limit, stop pulling when the pool is saturated, and settle cleanly when the queue drains.
Files: src/pool.js
export async function runPool({ queue, handlers, concurrency = 4, untilEmpty = true }) {
let running = 0;
let processed = 0;
async function attempt() {
if (running >= concurrency) return;
const job = await queue.claim();
if (!job) return;
running += 1;
try {
await handlers[job.name](job);
await queue.complete(job.id);
processed += 1;
} catch (err) {
await queue.fail(job.id, err);
} finally {
running -= 1;
}
}
while (untilEmpty && (await queue.hasPending())) {
await Promise.all(Array.from({ length: concurrency }, attempt));
}
return { processed };
}Expected behavior: With concurrency 2, at no point do more than two handlers run at once, and the pool drains every pending job before resolving.
4. Handlers and failure handling
Register named handlers, translate thrown errors into retry plans, and keep handler code free of queue concerns.
Files: jobs/handlers.js
export const handlers = {
async sendEmail(job, { log }) {
if (!job.payload.to) throw new Error('missing recipient');
log('sent to ' + job.payload.to);
return { ok: true };
},
async resizeImage(job) {
if (job.payload.width <= 0) throw new Error('invalid width');
return { width: job.payload.width };
},
};
export function wrapHandlers(raw, { log = () => {} } = {}) {
return Object.fromEntries(
Object.entries(raw).map(([name, fn]) => [
name,
async (job) => fn(job, { log: (m) => log(name + ': ' + m) }),
]),
);
}Expected behavior: A handler throwing a validation error results in a retry plan rather than a crash, and a clean handler marks the job complete with its result.
5. Queue CLI
Expose enqueue and status commands, print human-readable queue counts, and exit non-zero when a required argument is missing.
Files: src/cli.js
import { createFileStore } from './store.js';
import { createQueue } from './queue.js';
import { handlers } from '../jobs/handlers.js';
const [command, name, payloadJson] = process.argv.slice(2);
const store = createFileStore(process.env.QUEUE_FILE ?? './queue.json');
const queue = createQueue({ store, handlers });
if (command === 'add') {
if (!name) { console.error('usage: queue add <name> [json]'); process.exit(1); }
const id = await queue.enqueue(name, payloadJson ? JSON.parse(payloadJson) : {});
console.log(id);
} else if (command === 'status') {
const counts = await store.counts();
console.log(JSON.stringify(counts));
} else {
console.error('unknown command');
process.exit(1);
}Expected behavior: Adding a job prints its id, status prints counts by state, and running the command without a job name exits with code 1 and a usage line.
6. Run loop and tests
Add a long-running run loop that polls for due jobs, and cover store, retry, pool, and CLI behaviour with tests.
Files: test/queue.test.js
import { createQueue } from '../src/queue.js';
test('retry moves a failed job back to pending with a delay', async () => {
const store = memoryStore();
const clock = { now: 1000 };
const handlers = { boom: async () => { throw new Error('fail'); } };
const queue = createQueue({ store, handlers, clock, now: () => clock.now });
const id = await queue.enqueue('boom', {}, { maxAttempts: 2 });
await queue.claim();
await queue.fail(id, new Error('fail'));
const job = await store.get(id);
expect(job.status).toBe('pending');
expect(job.runAt).toBeGreaterThan(clock.now);
});
function memoryStore() {
const map = new Map();
return {
async add(j) { map.set(j.id, j); },
async get(id) { return map.get(id); },
};
}Expected behavior: The suite passes deterministically with an injected clock, and a real run loop processes enqueued jobs within the polling interval.
Testing
Run npx vitest. Queue tests use an in-memory store and assert that enqueue returns a stable id, that nextDue respects runAt, and that an empty queue returns null. Retry tests inject a fake clock and assert backoff grows exponentially and caps, without sleeping. Persistence tests reload the store from disk and confirm pending jobs survive.
Troubleshooting
Pending jobs vanish after a restart
Check that claim writes the running state to disk before the handler starts; recovering requires knowing which jobs were in flight, and an in-memory-only claim loses that on a crash.
The pool runs more jobs than the configured concurrency
Count in-flight work before pulling rather than after; the guard belongs before claim, otherwise a burst of attempts each pass the limit check and all proceed.
Retry tests are slow and flaky
Never sleep in tests. Inject the clock and assert the computed runAt instead; the backoff math is the unit under test, not the passage of wall time.
Deployment
Run the CLI as a long-lived process under a supervisor that restarts it on exit; the persisted store means a restart resumes pending work. In containers, mount the queue file as a volume so state survives redeploys.
Known limitations
- A single JSON file is fine for thousands of jobs but becomes a bottleneck in the tens of thousands, where a real database is the right tool.
- The pool is in-process, so job throughput is bounded by one machine’s CPU.
Project overview
Background jobs are where reliability stops being theoretical. A queue that loses work on restart, retries forever, or runs unbounded concurrency teaches the wrong lessons; this project makes each of those decisions explicit and testable without pulling in Redis or a message broker.
The queue persists to a single JSON file, runs handlers through a bounded pool, and exposes the state through a CLI. Every timing decision is injected, so the retry tests are deterministic instead of sleeping.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The queue core is small because every external effect — the clock, the handler map, the persistence — is passed in rather than imported:
export function createQueue({ store, handlers, clock = Date, now = () => clock.now() }) {
async function enqueue(name, payload = {}, options = {}) {
const job = {
id: crypto.randomUUID(),
name,
payload,
status: 'pending',
attempts: 0,
runAt: now(),
maxAttempts: options.maxAttempts ?? 3,
};
await store.add(job);
return job.id;
}
async function runOnce() {
const job = await store.nextDue(now());
if (!job) return null;
return handlers[job.name](job, { enqueue });
}
return { enqueue, runOnce };
}
That single signature is the contract the rest of the project is built around: persistence, scheduling, and retry all operate on the same job object.
Setup
This project uses no runtime dependencies; the standard library provides timers, persistence, and identifiers, which keeps the queue inspectable end to end.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | Timers, file system persistence, and worker pool |
| vitest | ^2.1.0 | Deterministic tests with an injected clock |
Create the project and install:
mkdir job-queue && cd job-queue
npm init -y
npm install -D vitest@^2.1.0
Wire the CLI and the test runner in package.json:
{
"name": "job-queue",
"type": "module",
"scripts": {
"queue": "node src/cli.js",
"test": "vitest run"
}
}
Create the queue storage file path as a configurable location so tests can point it at a temporary directory and never touch your real queue.
Phase 1: Persistent job store
Implement a JSON store that serialises jobs with atomic writes and supports querying the next due job. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
import { readFile, writeFile, rename } from 'node:fs/promises';
export function createFileStore(path) {
let jobs = [];
let loaded = false;
async function ensure() {
if (loaded) return;
try { jobs = JSON.parse(await readFile(path, 'utf8')); }
catch (err) { if (err.code !== 'ENOENT') throw err; jobs = []; }
loaded = true;
}
async function flush() {
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(jobs, null, 2));
await rename(tmp, path);
}
return {
async add(job) { await ensure(); jobs.push(job); await flush(); },
async nextDue(at) {
await ensure();
const due = jobs.filter((j) => j.status === 'pending' && j.runAt <= at);
due.sort((a, b) => a.runAt - b.runAt);
return due[0] ?? null;
},
async update(id, patch) {
const i = jobs.findIndex((j) => j.id === id);
if (i >= 0) jobs[i] = { ...jobs[i], ...patch };
await flush();
},
};
}
Acceptance for this phase: Two writes followed by a reload both persist, the file always parses, and nextDue returns only jobs whose runAt is in the past.
Phase 2: Retry policy
Compute the next attempt time with exponential backoff and a cap, and decide when a job has exhausted its attempts. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function backoffDelay(attempt, { base = 100, cap = 30000, jitter = true } = {}) {
const raw = Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
if (!jitter) return raw;
return Math.round(raw / 2 + Math.random() * (raw / 2));
}
export function planRetry(job, now) {
const attempts = job.attempts + 1;
if (attempts >= job.maxAttempts) {
return { ...job, attempts, status: 'failed' };
}
return { ...job, attempts, status: 'pending', runAt: now + backoffDelay(attempts) };
}
Acceptance for this phase: A job with maxAttempts 3 and no jitter produces delays 100, 200, then fails on the third attempt, all asserted against an injected clock.
Phase 3: Bounded worker pool
Run due jobs concurrently up to a limit, stop pulling when the pool is saturated, and settle cleanly when the queue drains. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
export async function runPool({ queue, handlers, concurrency = 4, untilEmpty = true }) {
let running = 0;
let processed = 0;
async function attempt() {
if (running >= concurrency) return;
const job = await queue.claim();
if (!job) return;
running += 1;
try {
await handlers[job.name](job);
await queue.complete(job.id);
processed += 1;
} catch (err) {
await queue.fail(job.id, err);
} finally {
running -= 1;
}
}
while (untilEmpty && (await queue.hasPending())) {
await Promise.all(Array.from({ length: concurrency }, attempt));
}
return { processed };
}
Acceptance for this phase: With concurrency 2, at no point do more than two handlers run at once, and the pool drains every pending job before resolving.
Phase 4: Handlers and failure handling
Register named handlers, translate thrown errors into retry plans, and keep handler code free of queue concerns. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
export const handlers = {
async sendEmail(job, { log }) {
if (!job.payload.to) throw new Error('missing recipient');
log('sent to ' + job.payload.to);
return { ok: true };
},
async resizeImage(job) {
if (job.payload.width <= 0) throw new Error('invalid width');
return { width: job.payload.width };
},
};
export function wrapHandlers(raw, { log = () => {} } = {}) {
return Object.fromEntries(
Object.entries(raw).map(([name, fn]) => [
name,
async (job) => fn(job, { log: (m) => log(name + ': ' + m) }),
]),
);
}
Acceptance for this phase: A handler throwing a validation error results in a retry plan rather than a crash, and a clean handler marks the job complete with its result.
Phase 5: Queue CLI
Expose enqueue and status commands, print human-readable queue counts, and exit non-zero when a required argument is missing. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { createFileStore } from './store.js';
import { createQueue } from './queue.js';
import { handlers } from '../jobs/handlers.js';
const [command, name, payloadJson] = process.argv.slice(2);
const store = createFileStore(process.env.QUEUE_FILE ?? './queue.json');
const queue = createQueue({ store, handlers });
if (command === 'add') {
if (!name) { console.error('usage: queue add <name> [json]'); process.exit(1); }
const id = await queue.enqueue(name, payloadJson ? JSON.parse(payloadJson) : {});
console.log(id);
} else if (command === 'status') {
const counts = await store.counts();
console.log(JSON.stringify(counts));
} else {
console.error('unknown command');
process.exit(1);
}
Acceptance for this phase: Adding a job prints its id, status prints counts by state, and running the command without a job name exits with code 1 and a usage line.
Phase 6: Run loop and tests
Add a long-running run loop that polls for due jobs, and cover store, retry, pool, and CLI behaviour with tests. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { createQueue } from '../src/queue.js';
test('retry moves a failed job back to pending with a delay', async () => {
const store = memoryStore();
const clock = { now: 1000 };
const handlers = { boom: async () => { throw new Error('fail'); } };
const queue = createQueue({ store, handlers, clock, now: () => clock.now });
const id = await queue.enqueue('boom', {}, { maxAttempts: 2 });
await queue.claim();
await queue.fail(id, new Error('fail'));
const job = await store.get(id);
expect(job.status).toBe('pending');
expect(job.runAt).toBeGreaterThan(clock.now);
});
function memoryStore() {
const map = new Map();
return {
async add(j) { map.set(j.id, j); },
async get(id) { return map.get(id); },
};
}
Acceptance for this phase: The suite passes deterministically with an injected clock, and a real run loop processes enqueued jobs within the polling interval.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Queue tests use an in-memory store and assert that enqueue returns a stable id, that nextDue respects runAt, and that an empty queue returns null. Retry tests inject a fake clock and assert backoff grows exponentially and caps, without sleeping. Persistence tests reload the store from disk and confirm pending jobs survive.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The injected clock is what makes the whole suite fast: the retry logic can simulate an hour of backoff in microseconds, so the tests stay meaningful without slowing the build.
Extending the project
New capabilities slot into distinct seams: a priority field orders the pull, a dead-letter list captures exhausted jobs, and a pluggable store swaps JSON for SQLite without touching the scheduler. Because handlers register by name, adding a job type never edits the queue core.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.