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 })
});
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)
);
The uniqueness scope includes the actor. Never let one user's key reveal another user's operation.
Within one transaction:
- insert the operation;
- on conflict, read the existing row;
- reject reuse when
request_hashdiffers; - enqueue via an outbox row;
- return
202plus the operation URL.
{
"id": "exp_123",
"status": "queued",
"status_url": "/api/exports/exp_123"
}
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
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 };
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)