Workhorse
Producing work

Idempotent enqueue

Use scoped enqueue keys so repeated acceptance returns the original durable job.

A user double-clicks "Place order". Your API handler runs twice, two jobs get enqueued, and the customer gets two confirmation emails. Or a webhook provider redelivers, or a retrying client repeats a request that already succeeded. Enqueue idempotency stops the second job from being created at all: the repeated request returns the original job's ID and writes nothing.

This solves duplicate acceptance. Handler delivery remains at least once — the other half of the problem, covered at the end of this page.

Choose a stable key

Build the key from the business operation that should happen once: an order ID, an invoice number, a webhook delivery ID. A timestamp or random value cannot identify a replay, so it defeats the mechanism.

const jobId = await queue.enqueue("invoice.capture", { invoiceId }, {
  queue: "billing",
  idempotency: { key: `capture:${invoiceId}`, scope: "invoice-capture" },
});

If the same scope and key still exist, an equivalent request returns the original job ID. PostgreSQL adds no second job, no event, no queue placement, no notification — the call just tells you which job already owns the key.

Scopes keep unrelated features apart. Two features might both want the key order-123; separate scopes make them separate registrations. An omitted scope uses the shared "default" scope, which works well when every key already carries a feature prefix.

Read what PostgreSQL did

Use enqueueWithResult when application behavior or diagnostics need more than the stable job ID:

const result = await queue.enqueueWithResult(
  "invoice.capture", { invoiceId }, { idempotency: { key, scope: "invoice-capture" } },
);

result.outcome is an EnqueueOutcome: accepted for a new job, replayed for an equivalent idempotency request, replaced or non_replaceable for debounce, and coalesced for throttle. enqueue returns the same jobId while hiding that explanation.

What counts as the same request?

Returning the existing job is only safe when the repeat really is the same request. PostgreSQL fingerprints what was accepted: the queue, type, payload, concurrency key, sorted tags, attempt budget, retry policy, deadline, execution timeout, key retention, and an explicit runAt value.

If those fields match, the call is a replay and converges. If they differ, the same key is being reused for materially different work — a mistake, not a duplicate — so queue.enqueue throws EnqueueIdempotencyConflictError and rolls back the statement rather than letting either request silently win.

Every SDK raises a typed idempotency conflict: EnqueueIdempotencyConflictError in TypeScript and Python, and EnqueueIdempotencyConflictError through errors.As in Go. The error names the existing job and conflicting fields without accepting the changed request.

An omitted runAt remains omitted in the fingerprint. A later replay of an immediate job therefore does not conflict with the original acceptance time.

Read the enqueue decision

Use queue.enqueueWithResult when the caller needs to distinguish a new acceptance from a replay, debounce replacement, debounce refusal, or throttled request. It returns the retained jobId and an outcome describing PostgreSQL's decision.

If debounce returns non_replaceable, the result also carries a reason. That reason tells the caller whether the key belongs to an incompatible mode, the retained job is no longer pending, or the pending window elapsed. Other outcomes omit reason, so TypeScript narrows the result from its outcome.

Keys expire

Each binding lives for a retention window — 24 hours by default, configurable per request through ttlMs. After expiry, the same key can own a new job. That makes enqueue idempotency a replay window rather than a permanent business registry: it catches the double-click and the redelivery, not a repeat a year later. Size ttlMs to your longest realistic replay — a webhook provider's maximum redelivery horizon, for example.

Purging a ready or scheduled job releases its binding immediately, and maintenance removes expired bindings on its own cadence.

Your raw key is never stored

PostgreSQL stores the scope and a cryptographic hash of the key. Events, conflict errors, and operator views show only a bounded preview and a digest. You can safely build keys out of internal identifiers without them appearing on an operator's screen or in audit data.

Why do handlers still need idempotency?

One accepted job can run again: a worker dies mid-attempt, the lease expires, and another worker claims the same job. Enqueue idempotency never sees that — it deduplicates acceptance, not execution.

When repeating an external effect would be harmful, pass the job ID or a domain ID as the provider's own idempotency key. HandlerContext.checkpoint can reuse a completed stage, but a crash can still land after the effect and before the checkpoint commit — only the system performing the effect can close that final gap.

Use a stable domain value such as orderId as the key. TypeScript can call Queue.enqueueWithResult and read result.jobId from its EnqueueResult; Queue.enqueue returns only the accepted job identifier when the outcome details are unnecessary.

Next


Exact key bounds, fingerprint fields, expiry, and conflict diagnostics: architecture reference.