Debounced Autosave Race Conditions: How the Finalize Gate Exposed Three Silent Bugs
A finalize button on a section-based editor turned a well-behaved debounced autosave into a source of spurious 409s and unhandled promise rejections. The fix wasn't a bigger debounce — it was three small changes that make flush(), unmount, and fire-and-forget saves compose without racing each other.
Debounced Autosave Race Conditions: How the Finalize Gate Exposed Three Silent Bugs
A section-based assessment editor had been shipping autosaves the way most React editors do: useState for the value, a debounced effect that fires a PUT after four hundred milliseconds of quiet, a saved-versus-saving chip in the corner so the user knows the round-trip landed. It worked for months. The moment we shipped a Finalize button — a server-side lock that turns any subsequent write into a 409 Conflict — the same autosave started producing spurious 409s in Sentry, unhandled promise rejections in the console, and a class of bug we could not reliably reproduce in staging.
The fix ended up being three tiny commits. None of them changed the debounce delay. None of them touched the server. They all changed the same file — useDebouncedSave — and each closed one edge that the finalize gate had made visible. This is a writeup of the three debounced autosave race conditions that the lock exposed, why they were invisible until the server started rejecting late writes, and what the fixed hook looks like.
The bugs surfaced inside Wield, our recruiting-pipeline product (the cvflow codebase internally), on the section-based assessment workspace where a recruiter edits an AI-generated report section by section, then clicks Finalize to lock it before sending the PDF to a hiring manager. The same pattern shows up in any React editor that pairs a debounced PUT with a status-transition endpoint — publish, submit, freeze, whatever the verb is. If your debounce logic doesn't compose with the lock, the lock will find every seam.
The Debounce That Assumed Nothing Was In Flight
Our first useDebouncedSave hook looked like every debounce hook on the internet. A useEffect on the value schedules a setTimeout; when it fires, it awaits save(value), marks the state saved, and clears a pending ref. A flush() helper cancels the pending timer and calls save(pending) synchronously — used by the Finalize handler right before the status PUT, and by the PDF-render handler right before the report render. All standard.
The bug lived in exactly one word: cancels. The debounce timer had already elapsed a hundred milliseconds ago; the await save(value) inside was mid-flight. flush() saw a null timer, decided there was nothing to cancel, and called save(pending) a second time. Two concurrent PUTs of the same body hit the server. The first one won. The second one landed after the finalize PUT had already flipped the row to finalized — and returned 409. The UI logged a "spurious late-409 after finalize" and dropped the user's last edit even though the write had actually landed.
The fix is a single ref, inflight, that holds the in-flight save's promise:
const inflight = useRef<Promise<void> | null>(null)
// inside the debounce timer callback:
const run = (async () => {
try {
await save(value)
last.current = value
if (pending.current === value) pending.current = null
setState("saved")
} catch {
setState("idle")
}
})()
inflight.current = run
void run.finally(() => {
if (inflight.current === run) inflight.current = null
})
Now flush() awaits inflight.current before deciding whether to fire again. If the in-flight save succeeded, pending.current is already cleared and the follow-up is a no-op. If it failed, the retry runs exactly once, with fresh context. The two-PUT window closes. The finalize 409 disappears.
The general shape here — that a "flush" primitive has to compose with an "in progress" primitive — is the same lesson we wrote up in useQuery vs useEffect for a Next.js dashboard: the moment two effects can race, you need a single source of truth for "is this request already in flight," or you write the race into the code and hope tests catch it.
The Unmount Cleanup That Fires a Second Concurrent PUT
We shipped the fix and the next round of review-loop findings landed the next morning. The same class of bug, in the same file, one useEffect over.
The cleanup for the debounce effect was written like every unmount-flush on the internet: if there's a pending value that hasn't been saved, fire the save one last time on the way out. It looked defensive. It read as "don't lose the user's last keystroke when they navigate away." It fired a second concurrent PUT of the same value if the debounced save was already in flight, because the cleanup ran the moment the component unmounted — not after the in-flight promise settled.
The route away from the assessment page was, of course, the Finalize handler: it navigates back to the list on success. The finalize PUT fired, the client unmounted the detail page, the unmount cleanup fired another PUT of the just-saved section body, and the server 409'd on it because the row was now finalized. Same category of bug as round one, different useEffect. The fix mirrors flush():
useEffect(() => {
return () => {
// Let an in-flight save settle first so we don't fire a duplicate
// concurrent PUT of the same value on unmount; only the still-unsaved
// remainder is retried.
const settle = inflight.current ?? Promise.resolve()
void settle.then(() => {
if (pending.current !== null && pending.current !== last.current) {
void saveRef.current(pending.current)
}
})
}
}, [])
The lesson generalises past autosave. Every fire-and-forget "on the way out" call has an implicit precondition: nothing else is already talking to the same endpoint about the same resource. React's own effect-cleanup docs frame the cleanup as "undo what the effect did," which reads as harmless. It isn't harmless when the effect is a mutation. Any cleanup that mutates has to answer the same question as flush(): is a mutation already in flight? If yes, wait. If no, go.
The Fire-and-Forget That Needed a .catch(() => {})
The third finding landed the same evening. With the unmount cleanup now correctly awaiting the in-flight promise before firing the retry, one edge remained: the retry itself is fire-and-forget. There is no component left to render a toast; there is no useEffect to re-run. The promise is orphaned by design.
But void in front of a promise does not swallow rejections. It only tells the compiler that the return value is intentionally ignored. If the retry PUT failed — because the finalize gate had already flipped the row to finalized, or because the network dropped mid-teardown — the rejection surfaced as an unhandled promise rejection in the browser console and in Sentry:
Unhandled Promise Rejection: Error: 409 Conflict — Assessment ist finalisiert
That entry is exactly zero-value. The component is already gone. The user has already navigated away. Nobody can act on it, nobody can retry it, nobody can even see a toast. It exists purely to make the error tracker louder. The fix is one line:
// Fire-and-forget by design: the component is gone, nobody can act on
// a failure — swallow instead of raising an unhandled rejection.
saveRef.current(pending.current).catch(() => {})
The precedent is MDN's guidance on unhandled rejections: a promise you intentionally do not await needs an explicit .catch, even if the catch is empty. void alone lies to the runtime. This is the kind of TypeScript-legal, ESLint-clean code that is nevertheless wrong — one of the patterns we called out in our production-failure playbook for AI agents: the loudest bugs are the ones that pass every static check.
Three Rules for a Debounced Save That Survives Finalize
The three fixes together are twelve lines of code. They read as trivia. What they are actually doing is turning a debounce hook that only had to compose with itself into a debounce hook that has to compose with a lock endpoint that can reject a late write while the client still thinks it's mid-conversation. Which is to say — with the reality of any UI that has a submit step.
Three rules survive the rewrite, and they generalise past this hook:
One in-flight save at a time. Track the promise in a ref. Any code path that might trigger another save — flush(), unmount cleanup, keypress after a fast save — awaits that ref before deciding to fire. This is the same discipline as request deduplication in TanStack Query: you cannot know whether a mutation is safe to retry unless you know whether the previous one has settled.
Cleanups that mutate need the same guard as manual flushes. React's unmount cleanup is not a magically-safe place to fire "one last save." It runs synchronously in the moment the component tears down, with none of the protections a foreground handler has. If it can fire a mutation, it can fire a duplicate mutation. Mirror flush() here or delete the cleanup — do not leave it as a smaller, weaker version of the flush.
Fire-and-forget needs an explicit empty catch. void promise is a type-system fiction. It compiles. It lints. It does not prevent unhandled-rejection noise when the underlying request fails, and the noise is worse when the request-fail is expected — a finalize 409 on a late retry, a network abort during unmount — because it teaches the on-call engineer to ignore the tracker. Attach .catch(() => {}) at the fire site and stop the noise where it originates.
The finalize gate did us a favour. Without a server that could reject a late write, the two-PUT window was invisible: the second write would land, be a no-op, and nobody would notice. The lock turned every seam into a red bar in Sentry, which is exactly what a good lock endpoint should do. If your app has an autosave and doesn't have a finalize-shaped endpoint yet, the seams are still there — you just haven't stress-tested them. If you are building one, the three rules above will save you two evenings.
If you are wiring up an editor with autosave and a submit step, and want a second pair of eyes on the debounce composition before it hits production — book a free AI Potenzial-Check — or read our Next.js worker split with pg_notify writeup for the server side of the same "durability under redeploy" theme.
wield · The recruiting pipeline that actually scales with your volume.
CV pipeline with AI dossier generation and evaluation. For recruiters sorting a hundred applications an hour — without losing quality.