DEV Community

kongkong
kongkong

Posted on

Make CSV Export Idempotent From Browser Click to Worker Completion

A user clicks Export twice because the first click shows no feedback. The API creates two jobs. A worker retries one after losing its acknowledgement, producing a third object. The UI eventually displays three links for the same request.

Disabling the button reduces accidental clicks; it does not make the workflow correct. The invariant belongs across every layer:

One authorized export intent maps to one durable operation and one logical artifact, despite duplicate delivery.

Define the operation

The browser creates an idempotency key once and keeps it through retries:

const key = crypto.randomUUID();
await fetch("/api/exports", {
  method: "POST",
  headers: { "Idempotency-Key": key, "Content-Type": "application/json" },
  body: JSON.stringify({ report: "orders", filters })
});
Enter fullscreen mode Exit fullscreen mode

The API authenticates first, canonicalizes the request, and stores:

CREATE TABLE exports (
  id UUID PRIMARY KEY,
  actor_id UUID NOT NULL,
  idempotency_key TEXT NOT NULL,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('queued','running','ready','failed')),
  object_key TEXT,
  created_at TIMESTAMPTZ NOT NULL,
  UNIQUE (actor_id, idempotency_key)
);
Enter fullscreen mode Exit fullscreen mode

The uniqueness scope includes the actor. Never let one user's key reveal another user's operation.

Within one transaction:

  1. insert the operation;
  2. on conflict, read the existing row;
  3. reject reuse when request_hash differs;
  4. enqueue via an outbox row;
  5. return 202 plus the operation URL.
{
  "id": "exp_123",
  "status": "queued",
  "status_url": "/api/exports/exp_123"
}
Enter fullscreen mode Exit fullscreen mode

Make the worker repeatable

Queue delivery is normally at least once. The worker must assume the same message can arrive before, during, or after completion.

Use a deterministic object key such as exports/{operation_id}.csv. Claim the job with a conditional transition, generate into a temporary object, then publish and mark ready. A duplicate worker that sees ready exits. A stale running lease can be reclaimed.

Do not mark the database ready before the artifact is readable. One practical sequence is:

claim lease
-> stream database snapshot to temporary object
-> validate row count/checksum
-> copy/rename to deterministic final key
-> transactionally set ready + artifact metadata
-> delete temporary object
Enter fullscreen mode Exit fullscreen mode

Object stores differ: rename may be copy-plus-delete, and read-after-write behavior must be checked for the chosen provider. Keep the provider seam explicit.

Authorization does not end at creation

GET /api/exports/:id must authorize the current actor against the operation. Return a short-lived signed download URL only for ready state. Regenerate an expired URL; do not rerun the export.

The stored object should be private, encrypted according to the data classification, and deleted by retention policy. CSV cells beginning with =, +, -, or @ may become formulas in spreadsheet software; escape untrusted textual fields according to the consuming environment.

Model UI states

type ExportState =
  | { kind: "submitting" }
  | { kind: "queued"; id: string }
  | { kind: "running"; id: string }
  | { kind: "ready"; id: string; downloadUrl: string }
  | { kind: "failed"; id: string; retryable: boolean };
Enter fullscreen mode Exit fullscreen mode

After a timeout, the browser resends the same key. After reload, it resumes from the stored operation ID. “Retry download” refreshes authorization; “retry export” creates a new intent only when the previous operation reached a terminal failure that policy permits retrying.

Cross-layer failure tests

Failure Expected result
double click one operation row
API commits, response lost retry returns same operation
same key, different filters 409 Conflict
queue delivers twice one logical final object
worker dies mid-stream lease expires; temporary object cleaned
ready response has expired URL refresh URL, do not regenerate
user requests another user's ID 404 or authorized denial
spreadsheet-control prefix exported as inert text

Also pin the data consistency contract. Does the export represent the database at request time, worker start, or multiple page-read times? For large exports, use a database snapshot or declare that rows may reflect a bounded moving window. “CSV export” is not a consistency specification.

Rollback checklist

Before rollout, keep the previous synchronous path available for a limited population, monitor operation age and duplicate conflicts, cap concurrent workers, and provide cleanup for abandoned temporary objects. Roll back creation traffic without deleting in-flight operations; workers must finish or be deliberately drained.

This design is more code than an SDK call because the feature is not “convert rows to CSV.” It is the browser-to-storage lifecycle, including identity, retries, authority, and recovery.

Top comments (0)