Module P-19·36 min read

Worker pools, priority queues, retry policy, cron that fires twice, and the long-running task that outlives the request that started it.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-19 — Background Jobs and Scheduling

What this module covers: Why void doTheWork() after a response is not a background job but a silent data-loss mechanism, and what turns deferred work into something you can operate: a durable record, a lease, a bounded retry policy, and a dead-letter destination. Enqueueing atomically with the business write that justified it. Retry policy split by error class, and the poison job that retries forever. Priority as separate queues rather than a priority column, because a single queue starves and head-of-line blocks. Worker concurrency for CPU-bound versus I/O-bound work, and why prefetch makes one slow worker everyone's problem. Long-running jobs: checkpointing, heartbeats that extend a lease, and cancellation. Then scheduling, where the interesting failures live — cron firing twice, cron not firing at all, the run that overlaps its predecessor, midnight thundering herds, and delayed jobs measured in weeks. Finally the two metrics that matter, which are not throughput.


The Work That Outlives the Request Needs Somewhere to Live

A user uploads a CSV of 40,000 rows. Or places an order that must email a receipt, notify the warehouse, update a search index, and recalculate a recommendation model. Or requests an export that takes ninety seconds. None of that belongs inside the HTTP request, for three separate reasons:

  • Latency. The user is waiting. Module F-4's point about tail latency applies with force: an endpoint whose p99 is 90 seconds has no usable SLO.
  • Capacity. Little's Law again — a 90-second request occupies a request slot for 90 seconds, so 200 concurrent exports is 200 held connections, 200 database sessions (Module P-4), and an exhausted web tier.
  • Reliability. A request can only be attempted once. If the warehouse notification fails, there is no mechanism inside a request/response cycle to try again, and the user has already been told the order succeeded.

So the work moves. The naive move is the one every codebase contains somewhere:

ts

Everything wrong with it is invisible in development and permanent in production. A deploy mid-flight kills the process and the receipt is never sent, with no record that it should have been. sendReceipt throwing produces an unhandled rejection, which in modern Node terminates the process and takes every other in-flight request down with it. Nothing retries. Nothing can be listed, counted, or re-driven. And under Module F-10's rule — no work happens after the response returns on serverless and edge runtimes — the callback may simply never execute, on a schedule determined by how quickly the platform freezes your container.

A background job replaces the hope with four things, and the whole module is those four things:

  1. A durable record of the intent, written before you respond.
  2. A lease so exactly one worker owns it at a time, and so a dead worker's job returns to the pool.
  3. A bounded retry policy, so transient failure is survived and permanent failure stops.
  4. A terminal destination for what never succeeds, so failure is a queryable list rather than an absence.

Analogy: a restaurant kitchen. The waiter does not stand at the table cooking; they write a ticket and clip it to the rail. The ticket is durable — it survives the waiter going on break. Exactly one chef takes it off the rail at a time, and if that chef walks out mid-service the ticket goes back on the rail rather than evaporating. A ticket that cannot be cooked ends up on a spike by the till where the manager reviews it, not in the bin. And critically, the customer was told "twenty minutes" rather than being asked to wait at the counter until it was ready.


Anatomy: a Job Is a Row With a Lease and an Attempt Count

Strip away the library and every job system has the same shape.

sql

The state machine is worth stating explicitly, because the interesting transitions are the ones people omit:

text

That last edge is the one that separates a job system from a to-do list. A worker claims a job and its process is killed — a deploy, an OOM kill, a reclaimed spot instance (Module O-8), a node drained by the orchestrator. Without a lease the row sits at running forever and the work never happens, with no error anywhere; this is the identical failure to Module P-16's poisoned idempotency key and Module P-18's leaked concurrency permit, and it recurs because a finally block does not run when a process is killed. Any ownership you take must expire on its own.

The claim itself has to be race-free, and in Postgres one statement does it:

sql

SKIP LOCKED is what makes this work at all: without it, twenty workers polling the same queue all block on the same first row and you have a serial job runner with twenty processes. With it, each worker takes a different row and the throughput is linear in worker count. It is the single most useful thing Postgres offers a queue, and it is why "just use your database" is a legitimate answer at moderate scale (Module P-14's list of conditions).

Note also attempts=attempts+1 at claim time, not at failure time. If you increment on failure, a job whose worker is killed mid-execution never records the attempt, so a job that reliably crashes the process retries forever — and takes a worker down each time. Incrementing on claim makes a crash count against the budget, which is what you want.

Enqueue atomically with the thing that justified it

The job must not exist if the order does not, and the order must not exist without the job. If the queue is your database, one transaction settles it:

sql

If the queue is external — SQS, Redis, Kafka — you have Module P-16's atomicity gap, and the answer is the same: write a row locally in the business transaction and let a relay publish it (the transactional outbox, Module A-3). Publishing to an external broker before the transaction commits is the bug that produces "the worker fetched an order that does not exist yet," which then fails, retries, and often succeeds on attempt two — making it look like a flaky race rather than an ordering error.


Retry Policy Is Two Decisions: How, and Whether

How is settled by Module A-6: exponential backoff with jitter, because fixed-interval retries from many workers synchronise into a herd against a dependency that is already struggling.

ts

Whether is the decision more often skipped, and it is the same split Module P-16 drew for idempotency keys:

Error classExampleAction
Transientdownstream 503, timeout, deadlock (40P01), connection resetretry with backoff
Rate limited429 with Retry-Afterretry, honouring the header rather than your curve (Module P-18)
Permanentvalidation failure, referenced row deleted, malformed payloadfail immediately to dead, do not retry
Unknownanything you did not classifyretry, bounded

Retrying a permanent failure eight times with exponential backoff means the job occupies your queue for eleven hours, produces eight identical error log entries, and pages nobody. Worse, the poison job — one whose payload reliably crashes the handler — retried without a cap is a worker-killing loop that can consume your entire pool. This is why max_attempts must be finite and why attempts increments at claim.

The dead-letter destination is a product feature, not a dustbin. A dead state that nobody looks at is identical to dropping the work, except it consumes storage. What makes it useful:

  • An alert on non-zero count per queue, because a dead job means a customer's receipt was never sent and somebody must decide what to do about it.
  • The full payload and the last error, so the failure is diagnosable without reproducing it.
  • A re-drive path — an operator action that moves dead jobs back to queued after the bug is fixed. This is the single highest-value operational tool in a job system, and it is worth building on day one, because the alternative during an incident is writing UPDATE statements against production by hand.

Because retries are at-least-once, every handler must be idempotent (Module P-16). The specific trap here: a handler that sends an email and then writes a row marking it sent will re-send the email if it crashes in between, and email is one of the effects with no absolute form — so the marking must come first, or the send must carry a dedupe key that the email provider honours.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.