Retrying Fetch Uploads with Idempotency Keys

Generate one key when the upload is created, send it as Idempotency-Key on every attempt including retries, and have the server record the first response against that key so a replay returns the original result instead of storing the file twice.

This article sits in the modern Fetch API for uploads topic inside upload fundamentals and browser APIs. It is about the safety half of retrying — the timing half, backoff and jitter, is covered in implementing exponential backoff for failed chunks.

When to use this approach

  • Your upload endpoint has a side effect that must happen exactly once: a database row, a billing event, a transcode job, a webhook to a downstream service.
  • The client retries automatically — which it should, because fetch rejects with TypeError: Failed to fetch for a dropped socket without telling you whether the request body reached the origin.
  • You are not writing to a content-addressed destination. A PUT to a fixed S3 key is already idempotent; overwriting the same bytes at the same key costs nothing and needs no key store.

Prerequisites

  1. Node 20+ (for global fetch, crypto.randomUUID, and AbortSignal.timeout) and TypeScript with lib: ["DOM", "ES2022"].
  2. A secure context in the browser — crypto.randomUUID() and crypto.subtle are undefined on plain http:// origins other than localhost.
  3. A durable store for the keys: PostgreSQL 14+ or Redis 7+. The examples use pg.
  4. CORS configured to allow the custom request headers, or the preflight fails before the upload starts.

Why a retry is dangerous without one

The failure that motivates all of this is the lost response, not the lost request. Your POST /uploads reaches the server, the server writes the object and the database row, and then the load balancer times out at 60 seconds, or the phone hands off from Wi-Fi to LTE, or a deploy rolls the pod. The client sees a rejected promise and has no way to distinguish “never arrived” from “arrived and worked”. Retry blindly and you get two objects in the bucket and two rows in your media table; refuse to retry and you fail an upload that actually succeeded.

An idempotency key collapses that ambiguity. The second request carries proof that it is the same request as the first, so the server can answer from its record instead of re-running the work.

Retry after a lost response returns the stored result The first upload succeeds but its 201 response never reaches the browser; the retry carries the same Idempotency-Key and the server replays the stored 201 instead of storing the file again. Lost response, safe retry Browser Upload API POST /uploads — Idempotency-Key: 9f2c1b object stored, row written, key marked completed 201 lost — TypeError: Failed to fetch retry — same Idempotency-Key: 9f2c1b no new object — read the stored response 201 Created (replay) — Idempotent-Replay: true
The first attempt succeeded; only its response was lost. The key turns the retry into a read.

Implementation

Two halves have to agree: a client that generates the key exactly once and never regenerates it, and a server that claims the key atomically before doing any work.

Client: one key per upload, persisted

export interface PendingUpload {
  id: string;
  file: File;
  idempotencyKey: string; // generated ONCE, at enqueue time
  sha256: string;
  attempts: number;
}

/** Call this when the user picks the file, not when you send the request. */
export async function createPendingUpload(file: File): Promise<PendingUpload> {
  const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
  const sha256 = [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
  return { id: crypto.randomUUID(), file, idempotencyKey: crypto.randomUUID(), sha256, attempts: 0 };
}

const RETRYABLE = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function delayFor(attempt: number, retryAfter: string | null): number {
  const header = Number(retryAfter);
  if (Number.isFinite(header) && header > 0) return Math.min(header * 1000, 30_000);
  const ceiling = Math.min(1000 * 2 ** attempt, 30_000); // 1s, 2s, 4s, 8s, 16s, 30s
  return Math.random() * ceiling; // full jitter
}

export async function uploadWithIdempotency(
  pending: PendingUpload,
  url: string,
  maxAttempts = 5,
): Promise<{ uploadId: string; storageKey: string }> {
  let lastError: unknown;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    pending.attempts = attempt + 1;
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: {
          // Identical on attempt 1 and attempt 5. Never call randomUUID() in here.
          "Idempotency-Key": pending.idempotencyKey,
          "Content-Type": pending.file.type || "application/octet-stream",
          "X-File-Sha256": pending.sha256,
        },
        body: pending.file,
        signal: AbortSignal.timeout(120_000),
      });

      if (res.ok) return await res.json();

      if (!RETRYABLE.has(res.status)) {
        // Deterministic rejection — a retry produces the identical failure.
        throw new Error(`upload rejected: HTTP ${res.status} ${await res.text()}`);
      }
      lastError = new Error(`retryable: HTTP ${res.status}`);
      await sleep(delayFor(attempt, res.headers.get("retry-after")));
    } catch (err) {
      const networkFailure = err instanceof TypeError; // "Failed to fetch"
      const timedOut = err instanceof DOMException && err.name === "TimeoutError";
      if (!networkFailure && !timedOut) throw err;
      lastError = err;
      await sleep(delayFor(attempt, null));
    }
  }
  throw lastError;
}

Server: claim the key, then do the work

import { createHash } from "node:crypto";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const json = (status, body, extra = {}) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json", ...extra },
  });

/** Hash the request shape, never the payload — you cannot buffer 500 MB to compare it. */
function fingerprint(req, userId) {
  return createHash("sha256")
    .update([
      req.method,
      new URL(req.url).pathname,
      userId,
      req.headers.get("content-length") ?? "",
      req.headers.get("content-type") ?? "",
      req.headers.get("x-file-sha256") ?? "",
    ].join("\n"))
    .digest("hex");
}

export async function handleUpload(req, userId, storeObject) {
  const key = req.headers.get("idempotency-key");
  if (!key || key.length < 16 || key.length > 255) {
    return json(400, {
      error: "idempotency_key_required",
      message: "Idempotency-Key header must be 16-255 characters.",
    });
  }
  const fp = fingerprint(req, userId);

  // Atomic claim: insert, or take over a lease that a crashed worker abandoned.
  const claim = await pool.query(
    `INSERT INTO idempotency_keys (user_id, key, fingerprint, state, leased_until, expires_at)
     VALUES ($1, $2, $3, 'in_flight', now() + interval '5 minutes', now() + interval '24 hours')
     ON CONFLICT (user_id, key) DO UPDATE
       SET leased_until = now() + interval '5 minutes'
       WHERE idempotency_keys.state = 'in_flight'
         AND idempotency_keys.leased_until < now()
         AND idempotency_keys.fingerprint = EXCLUDED.fingerprint
     RETURNING 1`,
    [userId, key, fp],
  );

  if (claim.rowCount === 0) {
    const { rows } = await pool.query(
      `SELECT fingerprint, state, response_status, response_body
         FROM idempotency_keys WHERE user_id = $1 AND key = $2`,
      [userId, key],
    );
    const row = rows[0];
    if (row.fingerprint !== fp) {
      return json(422, {
        error: "idempotency_key_reuse",
        message: "This Idempotency-Key was already used with a different request body.",
      });
    }
    if (row.state === "in_flight") {
      return json(409, {
        error: "idempotency_in_progress",
        message: "A request with this Idempotency-Key is still being processed.",
      }, { "retry-after": "2" });
    }
    return json(row.response_status, row.response_body, { "idempotent-replay": "true" });
  }

  try {
    const result = await storeObject(req.body); // streams to object storage
    const body = { uploadId: result.uploadId, storageKey: result.storageKey, bytes: result.bytes };
    await pool.query(
      `UPDATE idempotency_keys
          SET state = 'completed', response_status = 201, response_body = $3, leased_until = NULL
        WHERE user_id = $1 AND key = $2`,
      [userId, key, body],
    );
    return json(201, body);
  } catch (err) {
    // Infrastructure failure: release the key so the retry genuinely re-runs.
    await pool.query(`DELETE FROM idempotency_keys WHERE user_id = $1 AND key = $2`, [userId, key]);
    throw err;
  }
}

The table this relies on, with the composite key that scopes a key to its owner:

CREATE TABLE idempotency_keys (
  user_id         uuid        NOT NULL,
  key             text        NOT NULL,
  fingerprint     text        NOT NULL,
  state           text        NOT NULL CHECK (state IN ('in_flight', 'completed')),
  response_status int,
  response_body   jsonb,
  leased_until    timestamptz,
  expires_at      timestamptz NOT NULL,
  PRIMARY KEY (user_id, key)
);
CREATE INDEX idempotency_keys_expiry ON idempotency_keys (expires_at);

Line-by-line on the critical parts

  • crypto.randomUUID() inside createPendingUpload, not inside the fetch loop. This is the whole mechanism. A key minted per attempt is just a random header; the server sees five distinct keys and stores five objects. Generate it where the upload record is born, and persist it — see persisting upload state in IndexedDB so the key survives a page reload and the retry after a browser restart still deduplicates.
  • PRIMARY KEY (user_id, key) scopes the namespace per tenant. A global unique key on key alone lets one customer’s UUID collide with another’s — rare with v4 UUIDs, catastrophic with client-chosen keys like avatar-upload.
  • ON CONFLICT ... DO UPDATE ... WHERE ... leased_until < now() is the in-flight lock. Exactly one concurrent request gets rowCount === 1 and permission to do the work; everyone else falls through to the read path. The five-minute lease means a worker that is OOM-killed mid-upload does not wedge the key forever.
  • fingerprint() hashes headers, not the body. Comparing 500 MB payloads is not viable, so the fingerprint covers method, path, user, Content-Length, Content-Type, and the client’s X-File-Sha256. That is strong enough to catch a client that reuses a key for a different file.
  • json(row.response_status, row.response_body, { "idempotent-replay": "true" }) returns the original status verbatim — a replayed create is still 201, not 200. Downgrading the status forces clients to write two success branches. The extra header is for your own logs and dashboards.
  • DELETE in the catch block distinguishes “the request was rejected” from “our infrastructure fell over”. A deterministic 4xx should be recorded and replayed; an S3 timeout should not permanently poison the key.

The key’s lifecycle

A key record moves through three states, and each transition has a distinct HTTP answer. Getting these mapped correctly is what makes the client’s retry loop terminate.

State machine of an idempotency key record A key moves from absent to in_flight on insert, to completed on commit, and is purged after the twenty-four hour TTL; concurrent requests get 409 and mismatched bodies get 422. Key record states INSERT commit TTL 24h no row in_flight 5 min lease completed status + body purged 409 Conflict Retry-After: 2 replay stored 201 no side effects 422 mismatch other body One writer holds the lease; every other caller reads a deterministic answer.
Twenty-four hours is the usual TTL: long enough to outlive any client retry budget, short enough that the table stays small.

Pick the TTL from your longest plausible retry window, not from a round number. If your client can resume an upload the next morning from IndexedDB, 24 hours is too short and you want 72. Expire rows with a scheduled DELETE FROM idempotency_keys WHERE expires_at < now() — the expires_at index keeps it cheap — and store the responses as compact JSON, not full payload echoes, or the table outgrows the media metadata you index in PostgreSQL.

Which failures are safe to retry

The rule is simple: retry when the outcome is unknown, stop when the server has told you something deterministic. A 413 will be a 413 on attempt five. Classifying wrongly in either direction hurts — retrying a 400 burns the user’s data allowance, refusing to retry a 503 fails an upload that would have worked two seconds later.

Retryable versus terminal upload outcomes Network errors, timeouts, 429 and 5xx responses are retried with the same key; 400, 401, 403, 413, 415 and 422 are terminal. Retry classification Retry — outcome unknown TypeError: Failed to fetch DOMException: TimeoutError 429 Too Many Requests 500 / 502 / 503 / 504 409 — key still in flight same key, jittered backoff Terminal — deterministic 400 Bad Request 401 / 403 — auth 413 Payload Too Large 415 Unsupported Media Type 422 — fingerprint mismatch surface to the user, stop A 4xx fails identically on attempt five; only unknown outcomes deserve another attempt.
The left column is what the retry loop consumes; anything on the right must break the loop immediately.
Outcome Retry Why
TypeError: Failed to fetch Yes Chrome’s opaque network error; Safari says Load failed, Firefox NetworkError when attempting to fetch resource. The request may have landed.
TimeoutError from AbortSignal.timeout Yes The server may still be writing. The key makes the second attempt free.
AbortError from your own AbortController No The user cancelled. Retrying a cancel is a bug — see uploading files with fetch and FormData.
429 Yes Honour Retry-After if present; otherwise back off.
500, 502, 503, 504 Yes Transient by definition, and the key protects a partially-applied write.
409 from the key store Yes Another attempt holds the lease; wait 2 s and read the replay.
400, 413, 415, 422 No The request is wrong. It will be wrong again.

Configuration gotchas

Regenerating the key on retry. The single most common failure. headers: { "Idempotency-Key": crypto.randomUUID() } written inside the retry loop produces a fresh key per attempt, the server sees unrelated requests, and you get duplicate objects with zero errors in the logs — the bug shows up as a user complaint about “the same video three times”. Generate the key with the upload record and treat it as immutable.

Same key, different body. A client that reuses one key across two different files must be rejected, not silently served the first file’s response. The server answers 422 Unprocessable Entity with {"error":"idempotency_key_reuse","message":"This Idempotency-Key was already used with a different request body."}. Do not return 200 here: the client believes its second file was stored, and it was not.

Racing the in-flight window. Two tabs, or a retry that fires while the original is still streaming, both hit the same key. Without the ON CONFLICT claim you get error: duplicate key value violates unique constraint "idempotency_keys_pkey" (SQLSTATE 23505) bubbling out as a 500. With it, the loser gets 409 Conflict plus Retry-After: 2 and resolves cleanly on the next attempt.

CORS preflight strips the header. A cross-origin POST with Idempotency-Key triggers a preflight, and if the header is not in Access-Control-Allow-Headers the browser reports Request header field idempotency-key is not allowed by Access-Control-Allow-Headers in preflight response and the upload never leaves. List idempotency-key and x-file-sha256 explicitly — wildcards do not apply when credentials are involved.

Writing the key row in a different transaction from the work. If the object lands in S3 and the process dies before the UPDATE, the lease expires and the retry re-uploads. That is acceptable for object storage (the second write overwrites) but not for a billing event. Where the side effect is truly non-repeatable, write the domain row and the key row in one database transaction and commit before responding.

Verification

Fire the same key twice against a running server and compare:

KEY=$(uuidgen)
for i in 1 2; do
  curl -s -o /tmp/body-$i.json -w "attempt $i: %{http_code} replay=%{header_json}\n" \
    -X POST http://localhost:3000/uploads \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: image/jpeg" \
    -H "X-File-Sha256: $(sha256sum sample.jpg | cut -d' ' -f1)" \
    --data-binary @sample.jpg
done
diff /tmp/body-1.json /tmp/body-2.json && echo "identical body — replay confirmed"

Both attempts must return 201 with byte-identical bodies, the second carrying idempotent-replay: true. Then prove the mismatch guard fires:

curl -i -X POST http://localhost:3000/uploads \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: image/png" \
  -H "X-File-Sha256: 0000000000000000000000000000000000000000000000000000000000000000" \
  --data-binary @other.png
# Expect: HTTP/1.1 422 Unprocessable Entity  {"error":"idempotency_key_reuse",...}

Finally, confirm exactly one object exists — SELECT count(*) FROM media WHERE idempotency_key = :key must return 1, not 2. Pair this with the recovery drill in resuming uploads after network loss: kill the network mid-request with DevTools’ offline toggle, restore it, and check the count again.

Frequently Asked Questions

Should the key be a UUID or a hash of the file?

A crypto.randomUUID() per upload record is the default: it is cheap, unique, and lets a user deliberately upload the same file twice. A content hash also deduplicates across devices and page reloads for free, but it makes an intentional re-upload silently return the first result — a real problem for versioned documents. Use a UUID unless deduplication is the feature you want.

How long should keys live?

Longer than the client’s maximum retry window, and long enough to cover a resumed session. Twenty-four hours suits an in-page retry loop; go to 72 hours if uploads are persisted to IndexedDB and resumed after a restart. Purge on expires_at so the table does not grow without bound.

Does an idempotency key replace the client retry logic?

No — it only makes retries safe. You still need attempt limits, jittered delays, and failure classification, which are covered in browser timeout and retry logic. The key is the safety net under that machinery, not a substitute for it.

What should a replay return, 200 or the original status?

The original status, verbatim, plus a marker header such as Idempotent-Replay: true. Returning 200 where the first call returned 201 forces every client to handle two shapes of success and breaks any code that branches on res.status === 201.

Do presigned direct-to-cloud uploads need this?

The PUT to storage does not — writing the same bytes to the same key is naturally idempotent. The registration call that follows it does, because that is where the database row, the transcode job, and the webhook happen. Put the key on your own endpoint, not on the storage URL.