DEV Community

Cover image for The Retry-Safe API Answer: Build an Idempotency Guard in Node.js
Karuha
Karuha

Posted on • Originally published at aceround.app

The Retry-Safe API Answer: Build an Idempotency Guard in Node.js

The Retry-Safe API Answer: Build an Idempotency Guard in Node.js

The short interview answer is: a client retry must not create a second business operation. Give each mutation an idempotency key, bind that key to one request fingerprint, save the first outcome durably, and replay that outcome for matching retries. The hard part is not the header—it is deciding what “outcome” means when a dependency times out.

Diagram: client retry, reserve key, execute once, replay result

This comes up in backend and system-design interviews because real clients retry for ordinary reasons: a mobile connection drops, a load balancer gives up, or a caller times out before it receives a perfectly valid response. “Just retry it” is harmless for a read. It can charge twice, create two orders, or enqueue duplicate work for a write.

Here is a small Node.js drill that makes the contract concrete. Save it as idempotency-drill.mjs and run node idempotency-drill.mjs.

import assert from "node:assert/strict";
import { createHash } from "node:crypto";

const fingerprint = (body) =>
  createHash("sha256").update(JSON.stringify(body)).digest("hex");

function idempotent(handler) {
  const operations = new Map();

  return async ({ key, body }) => {
    if (!key) {
      return { status: 400, body: { error: "Idempotency-Key required" } };
    }

    const bodyHash = fingerprint(body);
    const existing = operations.get(key);

    if (existing) {
      if (existing.bodyHash !== bodyHash) {
        return {
          status: 409,
          body: { error: "Key was used with another payload" },
        };
      }

      return { ...(await existing.result), replayed: true };
    }

    // Store the promise before the side effect begins.
    const result = Promise.resolve().then(() => handler(body));
    operations.set(key, { bodyHash, result });

    try {
      return await result;
    } catch (err) {
      // Safe only when the handler proves its side effect did not happen.
      operations.delete(key);
      throw err;
    }
  };
}

let writes = 0;
const postOrder = idempotent(async ({ sku, quantity }) => {
  await new Promise((resolve) => setTimeout(resolve, 15));
  writes += 1;
  return {
    status: 201,
    body: { orderId: `ord_${writes}`, sku, quantity },
  };
});

const request = {
  key: "checkout-42",
  body: { sku: "tea", quantity: 2 },
};

const [first, retry] = await Promise.all([
  postOrder(request),
  postOrder(request),
]);

assert.equal(writes, 1);
assert.equal(first.body.orderId, "ord_1");
assert.equal(retry.body.orderId, "ord_1");
assert.equal(retry.replayed, true);

const conflict = await postOrder({
  key: "checkout-42",
  body: { sku: "tea", quantity: 3 },
});
assert.equal(conflict.status, 409);

console.log({ writes, first, retry, conflict });
Enter fullscreen mode Exit fullscreen mode

The expected result is one write, two responses that point to ord_1, and a 409 when a caller tries to reuse the key for a different payload.

What is the actual contract?

A good answer separates three cases:

Request situation Correct behavior Why
New key Reserve it, execute once, store the response This is the first attempt
Same key + same request Return the stored (or in-flight) result A retry should be boring
Same key + different request Reject it Reusing a key must not silently change an operation

The payload fingerprint matters. Without it, a buggy client could accidentally reuse a previous key while changing quantity: 2 to quantity: 3; replaying the old order would hide a real client bug. A hash is a compact comparison aid, not an authorization mechanism—authenticate the caller separately and scope the key to that caller or tenant.

The example returns the same promise to a concurrent duplicate request. That is useful because it prevents two in-process callers from running the handler twice. It also means a retry sent while the first attempt is still running waits for the original result instead of receiving a made-up “try again later” response.

Why the in-memory version is deliberately incomplete

Map is perfect for a drill and wrong for production. A process restart forgets every reserved key. Multiple application instances each have their own map. The production shape is a durable table or key-value record with a uniqueness constraint, roughly:

idempotency_records
  tenant_id
  idempotency_key
  request_fingerprint
  state             -- pending | completed | indeterminate
  response_status
  response_body
  expires_at
UNIQUE (tenant_id, idempotency_key)
Enter fullscreen mode Exit fullscreen mode

The first request must claim that unique key atomically. In SQL, that usually means an insert protected by a unique index; in Redis or another store, use an atomic create-if-absent operation. Do not use “read, then write” as two independent steps: two workers can both observe “missing” and both perform the charge.

A compact interview sentence is: “The database uniqueness constraint is the concurrency control, not a best-effort cache.”

The failure case interviewers are really probing

Suppose the service sends a payment request, then loses its connection before learning whether the provider accepted it. Deleting the idempotency record and retrying is dangerous: the side effect may already exist.

This is where the simple catch in the demo needs a policy:

  1. If failure happened before the durable side effect, release the key and let a later retry start fresh.
  2. If failure happened after a known success, persist the success response and replay it.
  3. If the outcome is unknown, persist an indeterminate state, reconcile with the downstream system using its operation identifier, and make retries return a clear retry/polling response until reconciliation completes.

That distinction demonstrates operational judgment. “Retry on every error” is not a reliability strategy; it is a duplicate-side-effect strategy.

A 60-second interview answer

If I were answering live, I would say:

“I require a client-generated idempotency key on write endpoints and scope it to the authenticated tenant. I atomically reserve the key with a fingerprint of the normalized request. A matching retry receives the original response; a mismatched reuse gets a conflict. I persist the record with a TTL, not just in memory, so restarts and horizontal scaling preserve the contract. For an uncertain downstream outcome, I do not blindly retry—I reconcile before allowing another side effect.”

Then invite the follow-up: “Do you want the payment flow, database transaction boundary, or multi-region version?” That shows you know the pattern is a contract with domain-specific edges, not a header you memorized.

Practical checks before you call an endpoint idempotent

  • Normalize the fields that define the operation before fingerprinting them.
  • Scope keys by tenant/user; never allow one customer to replay another customer’s result.
  • Store status and response body, not only a boolean, so retries get a meaningful answer.
  • Decide an expiration window that matches the business operation. A checkout key and a provisioning key need different lifetimes.
  • Monitor key conflicts, replay rate, pending age, and indeterminate records.
  • Test the timeout-after-side-effect path, not only the happy-path duplicate request.

Stripe’s idempotent requests documentation is a useful production reference for the “replay the first result” contract. The IETF’s Idempotency-Key HTTP header field RFC is helpful for the HTTP-level semantics and interoperability discussion.

For interview practice, an assistant such as aceround.app can play the follow-up interviewer: ask it to change the failure mode, add a second region, or challenge the TTL, then explain your trade-off out loud.

Disclosure: This post was drafted with AI assistance and reviewed for technical accuracy by the author. The code is a teaching drill; production systems need durable storage, authentication, and domain-specific reconciliation.

Top comments (0)