DEV Community

Teasa team
Teasa team

Posted on

A lost response is not a failed write: design retries around intent

Your server creates a resource, commits the transaction, and sends a response. The connection drops before the browser receives it.

The browser knows that it did not receive an answer. It does not know whether the resource exists. If Retry creates a new request identity, a second resource can appear.

The fix starts before the first attempt: capture one operation, then reuse its identity and payload until its outcome is known.

Bind the key to the payload

An idempotency key identifies one intended operation. It should not identify a button, an entire editing session, or every future action by a user.

Capture the serialized payload when the operation begins. Do not rebuild it from the current form on every retry; the form may have changed while the first request was running.

Save this client-side example as create-intent.mjs:

export function createIntent(key, draft) {
  const body = JSON.stringify(draft);
  return Object.freeze({
    key,
    body,
    async send(transport) {
      return transport({
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": key
        },
        body
      });
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

In a browser, create the key once, for example with crypto.randomUUID(). A transport adapter can pass the request to fetch for your API endpoint, validate the response, and return the resource identity.

The function above does not automatically retry. It preserves the operation so the caller can retry according to the API's error contract. An invalid input response needs correction. An ambiguous connection failure needs reconciliation of the existing intent.

Exercise the missing response

This test uses a tiny fake server to model one specific event: the first write succeeds, but its response is lost. It is deliberately a sequential simulation, not a durable server implementation.

Save it as create-intent.test.mjs and run node --test create-intent.test.mjs:

import test from "node:test";
import assert from "node:assert/strict";
import { createIntent } from "./create-intent.mjs";

test("retry reuses the original identity and serialized input", async () => {
  const draft = { title: "Original" };
  const intent = createIntent("operation-1", draft);
  const received = [];
  let stored;

  async function server(request) {
    received.push(request);
    if (!stored) {
      stored = { id: "resource-1", ...JSON.parse(request.body) };
      throw new Error("response lost after commit");
    }
    assert.equal(request.headers["Idempotency-Key"], "operation-1");
    assert.equal(request.body, received[0].body);
    return stored;
  }

  await assert.rejects(intent.send(server));
  draft.title = "Typed after the first attempt";
  assert.deepEqual(await intent.send(server), {
    id: "resource-1", title: "Original"
  });
  assert.equal(received.length, 2);
  assert.equal(intent.body, '{"title":"Original"}');
});
Enter fullscreen mode Exit fullscreen mode

Once the client recovers resource-1, it can apply the newer title through that resource's normal versioned update endpoint. Reusing the original create key with the newer title would give one identity two meanings.

The server must make admission atomic

A header alone prevents nothing. The server needs a durable decision for the pair (authenticated owner, operation key):

Existing record Incoming request Result
None Valid request Create one operation and record its identity
Present Same normalized input Return the existing operation
Present Different normalized input Reject the key reuse as a conflict

Authenticate first. Derive the owner from the session, and authorize any resources referenced by the input. A caller-supplied owner ID is not an authorization mechanism.

Store an input fingerprint with the operation. Define normalization before hashing: object-key ordering, omitted defaults, and equivalent values should have a documented treatment. Compare the same representation consistently on replay.

The lookup and creation must share a concurrency boundary. Two transactions can both observe that a record is absent. A unique constraint prevents duplicate keys, but the losing transaction still needs a defined replay path.

One PostgreSQL approach is to lock an existing owner row, check for a recorded key, and create the operation inside the same transaction. This also serializes unrelated admissions for that owner, so consider the throughput tradeoff. PostgreSQL's row-locking documentation explains the locking behavior. A unique owner/key constraint remains a useful final invariant.

If the operation invokes an external service, a local database transaction cannot roll that remote effect back. Pass a stable downstream idempotency key when supported, or design a reconciliation step. Recording “started” locally is not proof that a remote effect happened exactly once.

Make the retry lifetime explicit

Persist an unresolved intent if retries must survive a reload. Scope it to the signed-in account and protect sensitive payloads. Never replay one account's pending work after switching to another account.

Document how long the server retains replay records. A client should not blindly resend a very old key after that retention window; it may be treated as a fresh operation. Reconcile the resource first.

When testing the endpoint, include simultaneous same-key requests, same-key/different-input conflicts, separate owners using the same key, and a connection loss after commit. A successful second HTTP response matters less than whether both attempts resolve to the same intended resource.


At Teasa, creators build Stories and Characters in Creator Studio. Durable creation requests use an owner-scoped idempotency key so retrying an accepted request returns the existing job. The standalone example above isolates the client-side part of that design.

Try Teasa’s interactive AI stories to see the experience those creation tools support.

Top comments (0)