Published on 8/6/2026
Click Once, Cry Twice
Building an offline-safe, idempotent request queue on top of axios that survives flaky networks, page reloads, and duplicate taps
The Support Ticket
"I got charged twice for the same order." That was the ticket. User was on a train, spotty connection, hit "Place Order," saw nothing happen for a few seconds, hit it again. Two orders, two charges.
disabled state. Both requests actually reached the server, four seconds apart, identical payload. The first one had just been slow, the response never made it back before the user gave up and tapped again.That's when I realized the button-disable-on-click pattern I'd been shipping for years was never actually solving the problem. It solves "the same click fires twice." It does nothing for "the same intent fires twice because the first attempt never visibly resolved."
Digging Deeper, Found Two More
Once I started looking for this pattern, I found it in two more places.
One: a user on our internal dashboard would fill out a form, hit save, get a spinner, and if their wifi dropped mid-request, that was it. No error, no retry, the form data was just gone. They'd have to notice, and redo it. Nobody complained about that one loudly, they just quietly redid work and assumed the app was flaky.
Two, and this one was subtler: someone edited their profile fast, twice in a row, before the first save round-tripped. Both requests went out. The network reordered them, the second click's request arrived first, then the first click's stale data arrived last and overwrote it. The user closed the tab looking at their own fresh edit; a page reload later showed the old value.
Three different symptoms. Same root cause: a mutating request (POST/PUT/PATCH) has no concept of "I already tried this," so retries, double-taps, and network reordering all look identical to the server, just more requests.
First Idea (Wrong): axios-retry
axios-retry and call it done. I got about ten minutes into that before realizing it makes things worse, not better. axios-retry retries the exact same request object on failure. Fine for a GET. For a POST, retrying blind means: if the first attempt actually succeeded server-side and only the response got lost (exactly what happened with the train ticket), the retry creates a second order. I'd be automating the bug I was trying to fix.The retry needs to carry proof that it's a retry, not a new request. That's an idempotency key.
Layer 1: Agree With the Server First
Idempotency-Key header (a UUID) per logical operation, and the server has to actually honor it:Idempotency-Key: 8f14e45f-ceea-467e-9f8b-1c3a2b95e1e2
- Same key + same payload arrives again → server returns the cached response, doesn't reprocess
- Same key, first attempt still in-flight → server rejects with
409 Conflict - Same key + a different payload → server rejects, that's a client bug wearing a retry's clothes
The server-side store, the atomic claim on the key, the response cache, the TTL — all of that is backend work and out of scope for a frontend repo. What I owned was making sure the client always sent this header, kept it stable across retries of the same operation, and never reused it across two different operations.
Layer 2: Make It Free, So Nobody Forgets
I didn't want every call site to remember to generate a key. So I put it in the request interceptor we already had, right next to where the auth header gets attached:
// Mutating methods where duplicate execution risks data corruption or double charges
const MUTATION_METHODS = new Set(['post', 'put', 'patch'])
const onRequest = (
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const accessToken = TokenStorage.getAccessToken()
if (accessToken) {
config.headers.set('Authorization', `Bearer ${accessToken}`)
}
const method = config.method?.toLowerCase()
if (
method &&
MUTATION_METHODS.has(method) &&
!config.headers.get('Idempotency-Key')
) {
config.headers.set('Idempotency-Key', crypto.randomUUID())
}
return config
}
client now gets a key automatically, unless a call site already set one. This alone would have prevented a double-processed request within a single attempt.But it didn't fix the train ticket. An interceptor is a synchronous pass-through, it runs once per outgoing call and then forgets the call ever happened. It has no way to notice a request failed and go retry it thirty seconds later, especially not after the user reloaded the page. That needed to be stateful, which meant it needed to be its own thing.
Layer 3: The Part That Remembers
Here is how the queue and interceptor collaborate across network blips and page reloads:
PersistentRequestQueue, sitting next to client, wrapping it instead of replacing it, so it inherits the same auth header and error-toast interceptors for free.export interface QueueItem {
id: string
idempotencyKey: string
routeKey: string
method: string
url: string
data?: unknown
status: 'pending' | 'processing' | 'failed'
attempts: number
maxAttempts: number
nextRetryAt: number
createdAt: number
lastError?: string
}
localStorage immediately, pending → processing → gone or failed.private upsert(item: QueueItem): void {
const items = this.getAll()
const i = items.findIndex((x) => x.id === item.id)
if (i >= 0) items[i] = item
else items.push(item)
this.save(items)
}
processing, mid-flight with no verdict, the queue resets it back to pending on next load so it gets retried:private recoverStaleProcessing(): void {
this.save(
this.getAll().map((i) =>
i.status === 'processing'
? { ...i, status: 'pending' as const, nextRetryAt: Date.now() }
: i,
),
)
}
That retry is only safe because of the idempotency key. It's generated once, at enqueue time, and reused across every attempt of that same item:
enqueue(method: string, url: string, data?: unknown, opts: EnqueueOptions = {}): string {
const item: QueueItem = {
id: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(), // one key, reused on every retry
routeKey: opts.routeKey ?? this.defaultRouteKey(method, url),
method,
url,
data,
status: 'pending',
attempts: 0,
maxAttempts: opts.maxAttempts ?? 5,
nextRetryAt: Date.now(),
createdAt: Date.now(),
}
this.upsert(item)
void this.process()
return item.id
}
When the queue actually dispatches an attempt, it explicitly forwards that key in the request headers:
private async execute(item: QueueItem): Promise<AxiosResponse> {
return client.request({
method: item.method,
url: item.url,
data: item.data,
headers: {
// Explicit key: Layer 2's interceptor sees it exists and leaves it alone
'Idempotency-Key': item.idempotencyKey,
},
signal: this.getOrCreateController(item.id).signal,
})
}
If the server already processed attempt one (the train-ticket scenario) and only the response got lost, attempt two arrives with the same key and gets the cached response back, no second order. If the server never saw attempt one, it just processes normally. Either path, exactly once.
429 Too Many Requests (rate limiting) and 409 Conflict (the server's way of saying "first attempt is still running, try again in a second"):private backoff(attempt: number): number {
const base = 1000 * 2 ** attempt
const jitter = Math.random() * 500
return Math.min(base + jitter, 60_000)
}
private isNonRetriable(error: AxiosError): boolean {
const status = error.response?.status
// Retry 429 (rate limits) and 409 (transient in-flight conflicts)
return (
!!status &&
status >= 400 &&
status < 500 &&
status !== 429 &&
status !== 409
)
}
routeKey that already has something pending or in-flight cancels and drops the old one first:if (replaceSameRoute) {
for (const stale of this.getAll().filter((i) => i.routeKey === routeKey)) {
this.controllers.get(stale.id)?.abort()
this.controllers.delete(stale.id)
this.remove(stale.id)
}
}
Two rapid saves to the same profile now collapse into one request, whichever was enqueued last. No more "the network reordered my two clicks and the stale one won." Last write wins, on purpose, instead of by network accident.
What Using It Looks Like Now
client directly and just get the auto-attached key, nothing changed there:await client.patch('/profile', { name: 'New Name' })
Anything that can't be allowed to silently vanish, checkout, an irreversible state change, gets enqueued instead:
function submitOrder(payload: OrderPayload) {
criticalRequestQueue.enqueue('post', '/orders', payload, {
routeKey: `orders:create:${payload.cartId}`,
})
}
routeKey. Left at its default (METHOD:url), same-route collapse would treat every POST /orders call, from every cart, as one logical operation, which is exactly wrong. Anything touching a collection needs an explicit key that includes the resource ID.The UI reads queue state instead of awaiting a promise:
const unsubscribe = criticalRequestQueue.subscribe((items) => {
const failed = items.filter((i) => i.status === 'failed')
if (failed.length) {
// surface a retry button per item.id, wired to criticalRequestQueue.retry(id)
}
})
localStorage with a lastError, waiting for retry(id) or dismiss(id).Where I Left It
I didn't try to solve everything in one pass. What's still open, on purpose:
routeKeydefaults toMETHOD:url. Fine for singleton resources, wrong for anything parameterized. Every call site touching a collection needs to pass an explicit key, I haven't audited every call site yet.subscribe()has no UI consumer yet. The hook to surface failed items exists; nothing renders the retry/dismiss button today.- Multi-tab isn't handled. Two tabs both polling the same
localStoragequeue could both grab the same item. Fixable withBroadcastChannelornavigator.locks, only worth it if multi-tab turns out to be a real usage pattern. localStoragetops out around 5-10MB. Fine for JSON bodies, not for file uploads, those need a different queue.- The actual server-side dedup store isn't mine to build. The client sends the key correctly; the atomic claim, cache, and TTL are backend work for someone else's PR.
The train-ticket bug hasn't come back. That's the real test: not the test suite, but the support queue.