Skip to content

Idempotency and dedup

Nwire handlers may run more than once — queue retries, at-least-once bus delivery, workflow replays. Idempotency means the second invocation produces the same outcome as the first, without duplicate side effects.

Where duplicates come from

SourceTypical trigger
Queue workerBullMQ redelivery after handler timeout
Event busNATS/JetStream or AMQP ack-after-failure
HTTP clientUser double-clicks, proxy retries
Workflow timerSaga step retried after partial failure

Patterns

1. Natural idempotency

Some operations are idempotent by shape:

  • PUT /tasks/:id/complete — completing an already-complete task is a no-op.
  • Actor methods guarded by state — Submission.review() rejects if not flagged.

Prefer modeling that makes retries safe without extra machinery.

2. Idempotency keys (commands)

For actions that create new resources, accept a client-supplied key:

ts
export const createPayment = defineAction({
  name: "payments.create",
  description: "Miri retries checkout after a flaky Wi‑Fi drop — same key, one charge.",
  input: z.object({
    idempotencyKey: z.string().uuid(),
    amount: z.number().positive(),
  }),
  handler: async (ctx) => {
    const { input } = ctx;
    const existing = await ctx.resolve("payments").findByKey(input.idempotencyKey);
    if (existing) return existing;
    // ... insert + emit
  },
});

Store keys with a unique index; duplicate dispatch returns the stored result.

3. Inbox dedup (events)

Cross-service consumers use defineInbox to record processed messageId values:

ts
export const billingInbox = defineInbox({
  name: "billing",
  description: "Stripe webhook retries — Miri's renewal fires once per invoice id.",
});

The runtime skips handler work when messageId was already processed.

4. Outbox + inbox together

Service A                         Service B
─────────                         ─────────
txn { row, outbox }  ──bus──▶  inbox dedup → handler
     flusher                           │
     (at-least-once)                  ▼
                                  idempotent handler

Write path: Drizzle transactional outbox. Read path: inbox table or projection keyed by messageId.

Testing

@nwire/trust-kit scenario C09 exercises in-process dedup on retried dispatches. For integration coverage, run queue tests with RUN_INTEGRATION=1 against BullMQ or the in-memory transport with forced redelivery.

See also

MIT licensed.