← Back to Logs

How Queue-Backed Job Systems Actually Work

Try the interactive lab for this articleTake the quiz (6 questions · ~6 min)

Most teams adopt background jobs for a sensible reason and then explain them badly.

The sensible reason is latency. The request path should not wait for a video transcode, a payroll export, a thousand outbound emails, a VAT report, or a webhook fan-out to twenty customer endpoints. The bad explanation is usually some vague line about “making it async”, as if the hard part were moving work off the HTTP thread.

That is not the hard part. The hard part starts the moment the request returns before the work is finished. Now the system has to preserve intent durably, deliver work to a worker, survive process crashes, retry partial failures, prevent duplicate side effects, expose progress, and let operators tell the difference between a temporary backlog and a poisoned queue.

A queue-backed job system is the machinery that handles that contract. It is not just a queue. It is the combination of a durable handoff, a delivery mechanism, a worker lease model, a retry policy, and a set of observability rules that let you trust the whole thing when the fleet is under stress.

This article walks through that machinery in the order it actually matters: the durable handoff from request to job record, how leases and acknowledgements create an at-least-once delivery contract, why worker loops are really lease managers, how retries and dead-letter queues should work, where idempotency has to sit, how ordering and concurrency are constrained, and which operational signals tell you that the system is falling behind before users tell you first.

What a queue-backed job system is

A queue-backed job system is a split execution model where the request path stores a durable job record, makes that job available to a queue, and one or more workers execute it later under a delivery contract that usually allows redelivery after failure.

That definition sounds dense because every clause matters.

Split execution model means the user-facing path and the work path are different lifecycles. The API might respond in 80 milliseconds. The job might run for 30 seconds. Those are no longer the same operation from a timing point of view, even if the user thinks they are one action.

Stores a durable job record means the system has written enough state that the intent survives a process crash. If an API returns 202 Accepted but the machine reboots and nobody can prove what work was accepted, the queue architecture is already wrong.

Makes that job available to a queue means some worker can discover it later without reading raw request logs or replaying the web tier. The queue can be a database table, a broker such as RabbitMQ, a managed service with visibility timeouts, or a log-backed system with a consumer group model. The brand matters less than the delivery semantics.

Executed later is the visible product benefit. Users do not wait for long work on the foreground path. Operators can scale the worker fleet independently. Burst traffic can be smoothed into a backlog instead of becoming synchronous timeouts.

Usually allows redelivery after failure is the part many architecture diagrams omit. Mature job systems do not assume a worker either finishes cleanly or loses the job forever. They assume workers die, nodes pause, networks split, and acknowledgements disappear.

A clean mental model is:

client request
-> validate and authorise
-> write durable job intent
-> expose job to queue
-> return job id or accepted response
-> worker leases the job
-> worker executes side effect
-> worker stores result and acknowledges

That is the mechanism beneath many familiar product features:

  • generating invoice PDFs after checkout
  • sending welcome emails and password reset emails
  • pushing webhooks to partner endpoints
  • rendering analytics exports or settlement batches

Not every asynchronous action needs a full queue-backed system. If the work is tiny, deterministic, and cheap enough to finish inside the request deadline, a plain synchronous call is often better. Queues add operational cost, duplicate-delivery risk, and a second state machine. They earn their keep when the work is slow, bursty, failure-prone, or needs an independent retry lifecycle.

A serious system also gives the caller a stable handle for later inspection:

POST /exports HTTP/1.1
Content-Type: application/json
 
{"tenantId":"acme-eu","month":"2026-05"}

Response:

HTTP/1.1 202 Accepted
Content-Type: application/json
 
{
  "jobId": "job_8f2d6f0f",
  "status": "queued",
  "statusUrl": "/jobs/job_8f2d6f0f"
}

That jobId is not a courtesy. It is the durable identity for the deferred work. Logs, retries, metrics, operator tooling, idempotency guards, and customer support tickets will all need it later.

The durable handoff is the whole boundary

The biggest design mistake in job systems is treating “send to queue” as the primary truth and everything else as decoration.

The real boundary is the durable handoff from foreground request to background intent. If that handoff is ambiguous, nothing later will save you.

Suppose an API in Dublin accepts a request to generate 40,000 invoices for a month-end export. The request path must do two things before it replies:

  1. persist the fact that this export job exists
  2. make the job discoverable by a worker

If those two steps are not coordinated, the system gets one of two classic failure modes.

Lost job: the API commits customer-facing state, crashes before publishing to the queue, and no worker ever sees the work.

Orphaned job: the API publishes first, then the transaction that should have created the business state rolls back, and workers execute against a state transition that never really happened.

This is why durable handoff patterns matter more than queue branding.

Backing pattern What becomes durable first Strength Common failure if done badly
Jobs table in the primary database The job row itself One transaction can define business state and job intent together Polling and lease contention if the table is naive
Database plus outbox plus broker Business state and outbox row Strong local truth plus scalable broker delivery later Forgetting the outbox publisher or dedupe on publish
Broker-first publish Message in the broker Fast fan-out for broker-native architectures Orphaned work if local state is not committed consistently

The jobs-table pattern is the most boring and often the safest starting point. The request transaction inserts a row such as:

CREATE TABLE jobs (
  id              uuid PRIMARY KEY,
  type            text        NOT NULL,
  status          text        NOT NULL,
  payload         jsonb       NOT NULL,
  run_at          timestamptz NOT NULL,
  attempts        integer     NOT NULL DEFAULT 0,
  max_attempts    integer     NOT NULL DEFAULT 8,
  leased_until    timestamptz,
  worker_id       text,
  created_at      timestamptz NOT NULL DEFAULT now(),
  completed_at    timestamptz,
  last_error      text
);

If the same database transaction also records the primary domain change, the handoff is atomic. Either both the domain state and the job intent exist, or neither does.

The outbox pattern is the next step when you want a separate broker for throughput, fan-out, or operational isolation. The request transaction writes business state and an outbox row together:

CREATE TABLE job_outbox (
  id              bigserial PRIMARY KEY,
  topic           text        NOT NULL,
  message_key     text        NOT NULL,
  payload         jsonb       NOT NULL,
  published_at    timestamptz
);

Transaction sketch:

BEGIN;
 
INSERT INTO exports (id, tenant_id, month, status)
VALUES ('exp_2041', 'acme-eu', '2026-05', 'queued');
 
INSERT INTO job_outbox (topic, message_key, payload)
VALUES (
  'jobs.invoice-export',
  'exp_2041',
  '{"jobId":"job_8f2d6f0f","exportId":"exp_2041"}'::jsonb
);
 
COMMIT;

A separate publisher process reads unpublished outbox rows, publishes them to the broker, and marks published_at. If that publisher crashes halfway through, it can republish on restart because the broker-side consumer must deduplicate by stable key anyway.

This is the point people sometimes resist. “Why do we need dedupe if the publish should happen once?” Because crashes do not respect your intention. If the publisher sends the message and dies before marking the outbox row, the only safe recovery is to send it again. That means the consumer contract must already tolerate repeats.

A practical rule follows from all of this: do not tell the caller the job was accepted until the durable handoff is complete. If the system cannot store the job record or its outbox event, the correct response is usually a clear failure such as 503 or 500, not a cheerful 202 and a hope that someone will reconstruct intent later.

Delivery is lease, ack, and redelivery

Once the handoff is durable, the next reality appears: a queue is not a magic pipe from request to finished work. It is a delivery contract.

In most production job systems, that contract is at-least-once delivery. That phrase is precise.

It does not mean “the queue will probably duplicate things”. It means the system would rather deliver the same job again than silently lose it after an ambiguous worker failure.

The queue usually moves each job through states that look like this:

  1. ready: visible to workers
  2. leased or in flight: one worker has temporary ownership
  3. acknowledged or completed: the worker proved completion and the queue will not redeliver
  4. expired lease: the worker did not acknowledge before the deadline, so the job becomes ready again

A concrete timeline makes the contract clearer:

12:00:00 job_8f2d6f0f becomes ready
12:00:02 worker-a leases it until 12:00:32
12:00:11 worker-a sends email via provider API
12:00:13 worker-a crashes before ack
12:00:32 lease expires
12:00:33 worker-b leases the same job

From the queue's point of view, redelivery at 12:00:33 is correct. The queue saw a lease that expired without proof of completion. It cannot assume the email was never sent. It also cannot assume it definitely was. The correct behaviour is to re-offer the job and rely on the worker side to make repeat execution safe.

This is why the most important queue primitives are not glamorous:

Primitive Purpose What breaks without it
Stable job identity Lets every attempt refer to the same logical work item Retries look like new jobs
Lease deadline or visibility timeout Limits how long one worker can hold the job silently Stuck jobs disappear forever
Acknowledgement Separates “I received it” from “I finished it” The queue cannot tell success from crash
Attempt count Preserves history across retries Poison jobs spin forever without context
Heartbeat or lease extension Supports long-running work safely Healthy long jobs redeliver too early

Different products expose these ideas differently.

  • A database-backed queue may implement leasing with leased_until and worker_id columns.
  • RabbitMQ expresses delivery and ack explicitly on the channel.
  • SQS-style systems use a visibility timeout that hides the message for a period.
  • Log-based systems shift the conversation toward consumer offsets and idempotent processing, but the same ambiguity around side effects still exists.

The same operational truth survives every implementation: if a worker can crash after the side effect but before the queue sees a definitive acknowledgement, duplicates are part of the contract.

People often say they want at-most-once delivery to avoid duplicates. What they usually mean is “duplicates are painful and we have not built idempotency yet.” At-most-once delivery is easy. You hand the message to the worker once and never redeliver. It is also a good way to lose work whenever a node dies at the wrong moment.

Exactly-once delivery is the phrase vendors use when they want the slide deck to end on a clean slogan. In a narrow sense, some systems can guarantee exactly-once publication into their own log or exactly-once consumption relative to a transactional sink they control. But if your worker sends an email, charges a card, writes to an object store, or calls a third-party API, the real engineering problem comes back immediately. You still need idempotent side effects.

So the honest working model for most queue-backed jobs is not exactly once. It is:

  • durable intent
  • at-least-once delivery
  • idempotent execution
  • explicit acknowledgement after durable completion

That model is less exciting and far more useful.

A worker loop is really a lease manager

People talk about worker processes as if they are just compute slots. In practice, a worker is a lease manager wrapped around a handler.

Its job is not only to run business logic. It has to claim work safely, track ownership, respect concurrency limits, renew long-running jobs, decide when a failure is retryable, publish completion, and shut down without leaving nonsense behind.

A database-backed reservation loop often looks roughly like this:

WITH picked AS (
  SELECT id
  FROM jobs
  WHERE status = 'ready'
    AND run_at <= now()
    AND (leased_until IS NULL OR leased_until <= now())
  ORDER BY run_at ASC, created_at ASC
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE jobs
SET status = 'running',
    worker_id = $1,
    leased_until = now() + interval '30 seconds',
    attempts = attempts + 1
WHERE id IN (SELECT id FROM picked)
RETURNING *;

That one query expresses a lot of queue behaviour.

  • FOR UPDATE SKIP LOCKED prevents several workers from grabbing the same row simultaneously.
  • run_at <= now() allows delayed jobs.
  • leased_until <= now() lets expired work re-enter service.
  • incrementing attempts on lease, not on final failure, preserves the real delivery history.

The handler loop around that reservation step normally needs five pieces of discipline.

1. Bounded concurrency

A worker that leases 500 jobs because the queue is full and then runs only 20 of them locally has lied to the rest of the fleet. Those 480 jobs are now invisible without actually being processed.

Lease only as many jobs as you can start promptly. If local concurrency is 16, reserve in batches that match real capacity.

2. Deadlines inside the worker

The queue lease is not the same as the handler deadline. A 30-second visibility timeout does not mean every job should be allowed to run for 30 seconds. If the job type normally finishes in 400 milliseconds, letting one hang for 30 seconds is a capacity bug.

Good workers set job-type deadlines smaller than the lease and leave time for cleanup, state writeback, and acknowledgement.

3. Heartbeats for long-running jobs

If an export takes four minutes because it renders 90,000 PDF pages, a 30-second lease is too short. The worker should heartbeat or extend the lease while it is still making real progress.

That heartbeat needs its own policy. Extending forever because the process is technically alive is not enough. The worker should only renew while useful progress indicators still move: bytes written, rows processed, pages rendered, remote chunk acknowledged, something concrete.

4. Completion write before ack

The worker should durably record the result state before it acknowledges queue completion. Otherwise the system can acknowledge successfully, crash before writing the result row, and leave the caller with a completed side effect and no local proof.

The durable order usually wants to be:

  1. perform the side effect safely
  2. store local completion and result pointer
  3. acknowledge delivery

5. Graceful shutdown

A worker pool will be restarted during deploys, node drains, kernel updates, and autoscaling events. The process should stop leasing new work first, then either finish current jobs or release them cleanly.

A good shutdown path often looks like this:

SIGTERM arrives
-> stop polling or reserving
-> mark worker as draining
-> finish or checkpoint current jobs up to a deadline
-> if a job cannot finish, let the lease expire or release explicitly
-> exit

Without that discipline, deployments look like random retry storms.

There is also simple throughput math that matters more than many teams admit. If average execution time is 250 milliseconds, local concurrency is 8 per process, and you run 6 healthy worker processes, your rough steady-state service rate is:

(6 processes × 8 concurrent jobs) / 0.25 s = 192 jobs per second

That is only the first approximation because p95 and p99 runtimes matter, retries consume capacity, and one slow dependency can pin local slots. But if the system is receiving 300 jobs per second and can complete only 192, the queue is not “temporarily busy”. It is mathematically falling behind.

Retries need policy, not hope

A retry mechanism is not evidence of resilience. Blind retries are one of the fastest ways to convert a contained fault into a fleet-wide incident.

A mature queue-backed job system classifies failures before deciding what to do next.

Three broad classes are usually enough to make sane decisions.

Transient failures

These are faults likely to clear on their own:

  • network timeouts
  • connection resets
  • 429 Too Many Requests
  • 503 Service Unavailable
  • brief database failover windows
  • object-store throttling
  • temporary lock contention

These usually merit retry with backoff and jitter.

Deterministic failures

These will not succeed on the next attempt unless something external changes materially:

  • malformed payload
  • unknown tenant id
  • missing template name
  • unsupported file format
  • violated business invariant
  • code bug in a handler path

Retrying these immediately is theatre. It burns capacity and hides the real problem behind a rising attempt count.

Environmental or policy failures

These sit between the first two:

  • a downstream dependency is down for hours
  • a feature flag disabled the integration
  • a partner API quota has been exhausted for the day
  • a maintenance window blocked execution

These may still be retryable, but usually with much slower schedules or with queue-level pausing rather than per-job hammering.

A practical retry policy stores enough metadata to answer basic questions later:

  • when did the first attempt start?
  • how many attempts have happened?
  • what was the last error class?
  • when will the next attempt be eligible?
  • what is the final deadline or max age for this job?

A normal exponential-backoff schedule might look like:

attempt 1 -> immediately
attempt 2 -> 5 s later
attempt 3 -> 30 s later
attempt 4 -> 2 min later
attempt 5 -> 10 min later
attempt 6 -> 1 h later

But the exact numbers are less important than the intent.

  • Backoff reduces pressure on a sick dependency.
  • Jitter stops thousands of workers from retrying in lockstep.
  • Max attempts stops hopeless work from burning the fleet forever.
  • Max age prevents a two-day-old “send welcome email” job from finally succeeding when the context is no longer useful.

Dead-letter queues exist for the jobs that exhausted that policy. A dead-letter queue is not a bin for annoying errors. It is an operator-facing holding area for work that the automatic path should no longer touch.

Useful dead-letter context includes:

  • original job id and type
  • payload version
  • attempt count
  • first and last error timestamps
  • final error classification
  • worker id that parked it
  • links to traces or logs

That metadata lets an operator decide whether to:

  • fix the bad payload and replay
  • deploy a code fix and requeue a class of jobs
  • cancel the work permanently
  • contact a customer because the original input was invalid

One more failure shape deserves explicit attention: outage amplification. If a downstream SMTP provider in Frankfurt starts returning 503 to every email send and 40,000 queued email jobs all retry on the same 10-second cadence, the worker fleet becomes a denial-of-service tool pointed at the dependency and at itself.

This is why serious systems combine per-job retries with fleet-level protection such as circuit breakers, retry budget caps, and queue pausing. Sometimes the correct response to a dependency outage is not “retry every failed job more carefully”. It is “stop consuming this queue for 15 minutes and protect everything around it”.

Idempotency is how you survive duplicate delivery

The hardest queue bug to explain to people outside systems work is this one: a job can succeed, the worker can still fail, and the queue can still be correct to run the job again.

The simplest example is email.

  1. Worker A leases job job_8f2d6f0f.
  2. It calls the provider API and the provider accepts the message.
  3. Before Worker A stores local completion and acknowledges the queue, the process crashes.
  4. The lease expires.
  5. Worker B receives the same job.

If Worker B sends the same email again, the product now has a duplicate side effect. If Worker B refuses to run because “this already probably happened”, the system may silently lose completion. Neither answer is good enough.

The correct answer is idempotent execution.

That means the worker needs a stable way to recognise that repeated delivery refers to one logical side effect. There are several common techniques.

Reuse the job id as the side-effect key

If the external system supports an idempotency key, use the stable job id or a stable derivative of it.

For example, an email provider request might include:

POST /send
Idempotency-Key: job_8f2d6f0f

If the first provider call succeeded, a replay with the same key should return the first outcome rather than send a second email.

Persist a processed-effects record locally

A local table can guard side effects or map repeated attempts back to the original outcome:

CREATE TABLE job_effects (
  job_id            uuid PRIMARY KEY,
  effect_key        text        NOT NULL UNIQUE,
  provider_ref      text,
  status            text        NOT NULL,
  completed_at      timestamptz,
  response_payload  jsonb
);

Execution sketch:

  1. try to insert job_id and effect_key in started state
  2. if the row already exists as completed, replay the stored outcome
  3. call the external service with the stable key
  4. update the row to completed with provider reference
  5. then mark the queue job complete

Use deterministic resource names

For generated files, object storage helps if you choose deterministic keys. Writing an export to:

exports/acme-eu/2026-05/invoice-export.pdf

is naturally safer than writing to a fresh random path on every attempt. Repeated jobs converge on one object identity. You still need to define overwrite and partial-write rules, but the shape is already more idempotent.

Put uniqueness where the business effect lives

If a job posts a ledger movement, inserts a domain event, or records a webhook delivery, the strongest duplicate guard is often a unique business key or unique constraint in the target table itself. Queue dedupe should complement business truth, not replace it.

This is the real content behind “exactly once” in most real systems. The queue can deliver at least once. The worker can crash after the side effect. The only durable defence is that the side effect path itself converges repeated attempts onto one outcome.

It also helps to be explicit about what idempotency is not.

  • It is not merely logging that the same job ran twice.
  • It is not best-effort checking with a cache that can be evicted.
  • It is not “we have never seen duplicates in staging”.
  • It is not relying on humans to delete the second email or reverse the second payment.

It is stable identity plus durable duplicate protection at the point where repeated execution would cause product harm.

Ordering and concurrency need explicit keys

A queue looks like a line. That visual metaphor misleads people.

Most production systems do not really need one global line of perfectly ordered work. They need a mixture of parallelism and a few carefully chosen serial boundaries.

If you force everything through one FIFO lane, you get simple mental models and miserable throughput. One slow job blocks unrelated work behind it. A single noisy tenant can dominate the fleet. Queue depth becomes less about total demand and more about one unlucky ordering bottleneck.

The first question should therefore be: what actually needs ordering?

Usually the answer is not “all jobs of this type”. It is something narrower such as:

  • jobs for one account
  • jobs for one document id
  • jobs for one external destination endpoint
  • jobs for one tenant when that tenant's state must change in sequence

That gives you the partition key.

With a partition key, the system can preserve order where it matters and parallelise everything else. Tenants in Lisbon, Milan, and Helsinki do not have to wait behind each other if their jobs touch disjoint state.

This also changes how you think about concurrency. There are at least four useful concurrency limits in a serious worker fleet.

Global worker capacity

How many jobs can the whole fleet execute at once before CPU, memory, or node count become the bottleneck?

Per-job-type capacity

Image resizing, invoice rendering, webhook delivery, and settlement exports rarely want the same concurrency. A CPU-heavy PDF renderer should not compete on equal terms with tiny email jobs.

Per-key serialisation

If two jobs both mutate account_4819, the queue should not let them race just because two workers are idle.

Downstream rate limits

A queue can have endless local capacity and still be constrained by a partner API that allows 25 requests per second. Without a downstream limiter, scaling workers just increases how fast they hit 429.

A common design is to separate queues or pools by work class:

  • emails.high
  • emails.bulk
  • webhooks.partner-a
  • exports.month-end
  • thumbnails

That may look less elegant than “one shared jobs table for everything”, but it gives operators real control. They can scale bulk emails down during an incident, pause a misbehaving partner queue, or allocate more nodes to month-end exports without disturbing transactional work.

Global ordering is sometimes required, but it should be treated as a special cost, not a default. The cost is head-of-line blocking and lower throughput. If the business rule is really “every event for this ledger must be applied in sequence”, then pay that cost consciously for the ledger key, not for the entire platform.

Scheduling and priority are separate systems

The next misconception is that delayed jobs, cron jobs, and priority jobs are all just ordinary queue items with extra flags. They are related, but they create different operational problems.

Delayed jobs

A delayed job should not occupy a worker slot while waiting. The normal pattern is to store run_at on the job row or a scheduled set entry and have a promoter move due work into the ready queue.

That promoter might run every second, every five seconds, or via a timing-wheel structure for large fleets. The mechanism matters because millions of sleeping jobs are a very different scaling problem from thousands of ready jobs.

A simple database-based promoter looks like:

UPDATE jobs
SET status = 'ready'
WHERE status = 'scheduled'
  AND run_at <= now()
RETURNING id;

That is enough at moderate scale. At higher scale, the promoter usually needs careful indexing, batching, and partitioning so that “what is due now?” does not become the hottest query in the platform.

Periodic jobs

Periodic work such as daily statement generation or hourly reconciliation adds two constraints: calendar semantics and duplicate scheduling risk.

If a job runs every day at 00:05 Europe/Amsterdam, daylight-saving transitions matter. Some local times happen twice. Some never happen. If you store only naive timestamps and pretend the calendar is flat, billing and reporting jobs will eventually surprise you.

A good periodic scheduler therefore stores:

  • the business schedule definition
  • the timezone or UTC rule
  • the last successful fire time
  • a stable logical schedule key

That stable key matters because scheduler instances also fail and retry. If two scheduler nodes both believe they should enqueue the June closeout job, the duplicate guard should collapse them.

Priority jobs

Priority is not free either. A single integer priority field can help, but it also creates starvation if high-priority traffic never stops.

This is why many teams prefer explicit queues and weighted polling over one infinite priority heap. A worker can poll transactional queues more often than bulk queues, or reserve a portion of local slots for urgent work while still draining the background lane slowly.

A practical weighted loop might behave like:

poll payments once
poll webhooks once
poll exports once every 3 cycles
poll bulk email once every 5 cycles

Observability should describe backlog in time, not just count

Queue depth is the first graph people build and the one most likely to mislead them if it stands alone.

A backlog of 20,000 jobs could be trivial if each job takes 5 milliseconds. A backlog of 200 jobs could be severe if each one takes 90 seconds and users are waiting on them interactively.

Time-based signals are usually closer to the user experience.

The most useful metrics for queue-backed job systems are usually:

  • ready job count
  • in-flight job count
  • age of the oldest ready job
  • end-to-end latency from enqueue to completion
  • execution time by job type
  • attempt histogram
  • retry rate
  • dead-letter rate
  • lease expiry count
  • ingress rate versus completion rate

If I had to pick one alerting signal for a user-visible background workflow, it would often be age of the oldest ready job. That metric tells you whether someone is now waiting much longer than the design intended, regardless of whether the absolute queue depth is small or huge.

You can also do very plain capacity math with it.

If the queue currently has 12,000 ready jobs and the fleet is completing 150 jobs per second, then the approximate drain time with zero new arrivals is:

12000 / 150 = 80 seconds

If the same fleet is receiving 200 new jobs per second, the drain time is infinite because the backlog is growing, not shrinking.

Little's Law is a useful sanity check here: L = λW.

  • L is the average number of jobs in the system.
  • λ is the average completion rate.
  • W is the average time a job spends in the system.

If your export system processes 60 jobs per minute and you want average enqueue-to-complete time under 30 seconds, the steady-state number of jobs in the system needs to stay under about 30. If it is sitting at 400, the problem is not a dashboard configuration issue. The system is underprovisioned, overloaded, or stalled.

Logs should be structured enough to support the same questions. A log sequence like this is far more useful than generic stack traces:

job=job_8f2d6f0f type=invoice-export attempt=2 state=leased worker=worker-b
job=job_8f2d6f0f type=invoice-export attempt=2 state=effect-dedupe-hit export_id=exp_2041
job=job_8f2d6f0f type=invoice-export attempt=2 state=completed duration_ms=184

Now the operator can see that attempt two was a replay, not a second real export.

Observability is also where fleet pathologies become obvious.

  • Rising lease expiry count suggests workers are crashing, hanging, or holding jobs too long.
  • Stable queue depth but rising oldest-ready age suggests a starvation issue or a stuck partition.
  • Flat completion rate with rising retry rate suggests the fleet is burning capacity on errors rather than useful work.
  • A burst of dead letters right after deploy suggests a handler compatibility bug, not a random coincidence.

If the metrics cannot show those shapes, the queue may still function, but the team is operating it half blind.

Deployments break queues at the edges

Payload compatibility

A queued job can outlive the version of the application that created it. That means workers must not assume every payload matches the newest code shape.

Bad pattern:

{
  "serializedOrmObject": { "every_internal_field": "whatever the app had in memory" }
}

Better pattern:

{
  "version": 2,
  "jobId": "job_8f2d6f0f",
  "tenantId": "acme-eu",
  "exportId": "exp_2041"
}

Small, explicit payloads version better. The worker can branch on version, load fresh domain state, and keep compatibility across deploys.

Rolling restarts

During a rolling deploy, workers on the old version and workers on the new version may both see the same queue briefly. If the new version writes payloads the old version cannot even parse, retries during deploy become poison pills.

This is why migrations for queue payloads should usually be backward compatible first, then forward-only later after the fleet is fully rolled.

Shutdown behaviour

A worker node paused by autoscaling or drained by Kubernetes can leave ambiguous leases behind. If the shutdown path just kills the process, every in-flight job becomes a timed redelivery later. That is safe from a correctness point of view if idempotency is solid, but it may be terrible for latency and downstream cost.

Graceful draining is not polish. It is part of normal queue hygiene.

Schema migrations

Changing a handler often means changing tables the handler reads or writes. If old queued jobs still expect the previous schema, the migration plan has to account for that. This is especially important for long-delay queues, monthly batch jobs, and dead-letter replay tooling.

Replay tooling

Once dead-letter queues exist, humans will replay jobs. Replay tooling therefore needs the same safety properties as the automatic worker path: stable ids, dedupe, clear audit logs, and a way to replay one job or one cohort without creating a new business operation accidentally.

Sweepers for stuck work

Even well-designed systems need reconciliation jobs that look for work that violated expected invariants.

Examples:

  • jobs stuck in running with expired leases
  • jobs marked completed without a result pointer
  • outbox rows published to the broker but not marked locally
  • queue messages whose referenced job rows were deleted incorrectly

Those sweepers are not evidence the primary system is weak. They are evidence the team understands that distributed state machines need repair loops.

When to use a queue-backed job system

Queue-backed jobs are a good fit when all of the following are true.

  • the work can outlive the foreground request
  • retries are valuable because the work touches flaky or rate-limited dependencies
  • the user can tolerate polling, callback, or later completion
  • the platform needs to absorb bursts without dropping intent
  • operators need control over concurrency, pausing, replay, and failure analysis

That makes queues a strong tool for email, webhooks, exports, report generation, image processing, asynchronous settlement, and many partner integrations.

They are a poor fit when the caller actually needs a final answer now, when the operation needs one tight database transaction more than it needs elasticity, or when the workflow is long-lived human orchestration rather than background execution. If a process waits on human approval for two days, a workflow engine or explicit state machine is often a better fit than pretending it is one giant queue job.

Queues also do not remove the need for good synchronous design. A fast request path still needs clear timeouts, idempotent foreground mutations where relevant, and good error reporting. Moving work to a queue only changes where the uncertainty lives.

The useful mental model is not “a queue runs tasks later”. It is “the system has created a second execution plane with its own durability, delivery, and repair rules”. Once you see it that way, the design choices become clearer.

The queue brand matters less. The real questions are always the same.

  • Where is intent stored durably?
  • When does the caller learn the job exists?
  • What causes redelivery?
  • What makes repeated execution safe?
  • How do retries back off?
  • How do poison jobs stop burning capacity?
  • Which metric tells you that real users are waiting too long?

Answer those well and a queue-backed job system becomes dependable infrastructure. Ignore them and the queue becomes a place where failures disappear until a customer, a partner, or the finance team pulls them back into daylight.