Debounced-Autosave-Race-Conditions: Wie der Finalize-Gate drei stille Bugs offenlegte
Ein Finalize-Button auf einem sektionsbasierten Editor verwandelte einen wohlerzogenen Debounced-Autosave in eine Quelle spuriöser 409er und unbehandelter Promise-Rejections. Der Fix war kein längerer Debounce — sondern drei kleine Änderungen, die flush(), Unmount und Fire-and-Forget-Saves ohne gegenseitige Race Condition zusammenspielen lassen.
Debounced-Autosave-Race-Conditions: Wie der Finalize-Gate drei stille Bugs offenlegte
Ein sektionsbasierter Assessment-Editor hatte Autosaves so ausgeliefert, wie es die meisten React-Editoren tun: useState für den Wert, ein debounce-verzögerter Effekt, der nach vierhundert Millisekunden Ruhe ein PUT abfeuert, ein saved-oder-saving-Chip in der Ecke, damit die Nutzerin sieht, dass der Round-Trip gelandet ist. Das hat monatelang funktioniert. In dem Moment, in dem wir einen Finalize-Button ausgeliefert haben — ein serverseitiger Lock, der jedes nachfolgende Schreiben in einen 409 Conflict verwandelt — begann derselbe Autosave, spuriöse 409er in Sentry zu produzieren, unbehandelte Promise-Rejections in der Konsole und eine Kategorie von Bug, die wir im Staging nicht zuverlässig reproduzieren konnten.
Der Fix waren am Ende drei winzige Commits. Keiner davon hat die Debounce-Verzögerung angefasst. Keiner den Server. Alle betrafen dieselbe Datei — useDebouncedSave — und jeder schloss eine Kante, die der Finalize-Gate sichtbar gemacht hatte. Das ist die Aufzeichnung der drei Debounced-Autosave-Race-Conditions, die der Lock offengelegt hat, warum sie unsichtbar waren, bis der Server anfing, verspätete Writes abzulehnen, und wie der reparierte Hook aussieht.
Die Bugs traten in Wield auf, unserem Recruiting-Pipeline-Produkt (intern die cvflow-Codebasis), im sektionsbasierten Assessment-Workspace, in dem ein Recruiter einen KI-generierten Report Sektion für Sektion editiert und dann Finalize klickt, um ihn zu sperren, bevor das PDF an eine Hiring-Managerin geht. Dasselbe Muster taucht in jedem React-Editor auf, der ein debounce-verzögertes PUT mit einem Status-Transition-Endpoint kombiniert — Publish, Submit, Freeze, wie das Verb auch heissen mag. Wenn deine Debounce-Logik nicht mit dem Lock zusammenspielt, wird der Lock jede Naht finden.
Der Debounce, der davon ausging, dass nichts in Flight ist
Unser erster useDebouncedSave-Hook sah aus wie jeder Debounce-Hook im Internet. Ein useEffect auf den Wert plant ein setTimeout; wenn es feuert, ruft es await save(value) auf, setzt den Zustand auf saved und leert einen pending-Ref. Ein flush()-Helper cancelt den anstehenden Timer und ruft save(pending) synchron auf — verwendet vom Finalize-Handler direkt vor dem Status-PUT und vom PDF-Render-Handler direkt vor dem Report-Rendering. Alles Standard.
Der Bug wohnte in genau einem Wort: cancelt. Der Debounce-Timer war schon vor hundert Millisekunden abgelaufen; das await save(value) darin lief mitten in der Ausführung. flush() sah einen null-Timer, entschied, dass es nichts zu canceln gab, und rief save(pending) ein zweites Mal auf. Zwei nebenläufige PUTs desselben Bodys trafen den Server. Der erste gewann. Der zweite landete, nachdem das Finalize-PUT die Zeile bereits auf finalized umgelegt hatte — und lieferte 409. Das UI protokollierte eine „spuriöse verspätete 409 nach Finalize" und verwarf den letzten Edit der Nutzerin, obwohl der Write in Wahrheit gelandet war.
Der Fix ist ein einzelner Ref, inflight, der das Promise des in-flight-Saves festhält:
const inflight = useRef<Promise<void> | null>(null)
// im 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
})
Jetzt wartet flush() auf inflight.current, bevor es entscheidet, ob es erneut feuert. Wenn der in-flight-Save erfolgreich war, ist pending.current bereits geleert und der Follow-up wird zum No-op. Wenn er fehlgeschlagen ist, läuft der Retry genau einmal, mit frischem Kontext. Das Zwei-PUT-Fenster schliesst sich. Der Finalize-409 verschwindet.
Die allgemeine Form dahinter — dass ein „Flush"-Primitive mit einem „In-Progress"-Primitive zusammenspielen muss — ist dieselbe Lektion, die wir in useQuery vs. useEffect für ein Next.js-Dashboard aufgeschrieben haben: In dem Moment, in dem zwei Effekte gegeneinander laufen können, brauchst du eine einzige Wahrheitsquelle für „ist dieser Request bereits unterwegs", oder du schreibst die Race Condition in den Code und hoffst, dass Tests sie fangen.
Der Unmount-Cleanup, der ein zweites nebenläufiges PUT abfeuert
Wir haben den Fix ausgeliefert, und am nächsten Morgen kam die nächste Runde Review-Findings. Dieselbe Bug-Klasse, in derselben Datei, einen useEffect weiter.
Der Cleanup für den Debounce-Effect war so geschrieben wie jeder Unmount-Flush im Internet: Wenn es einen pending-Wert gibt, der noch nicht gespeichert wurde, feuere den Save auf dem Weg raus ein letztes Mal ab. Es sah defensiv aus. Es las sich als „verlier nicht den letzten Tastenanschlag der Nutzerin, wenn sie wegnavigiert." Es feuerte ein zweites nebenläufiges PUT desselben Werts ab, wenn der debounce-verzögerte Save bereits unterwegs war, weil der Cleanup in dem Moment lief, in dem die Komponente unmountete — nicht nachdem das in-flight-Promise settelte.
Der Weg weg von der Assessment-Seite war natürlich der Finalize-Handler: Bei Erfolg navigiert er zurück zur Liste. Das Finalize-PUT feuerte, der Client unmountete die Detailseite, der Unmount-Cleanup feuerte ein weiteres PUT mit dem gerade gespeicherten Sektions-Body ab, und der Server 409'te darauf, weil die Zeile jetzt finalized war. Dieselbe Kategorie Bug wie in Runde eins, anderer useEffect. Der Fix spiegelt flush():
useEffect(() => {
return () => {
// Lass einen in-flight-Save zuerst settlen, damit wir kein doppeltes
// nebenläufiges PUT desselben Werts auf Unmount abfeuern; nur der
// wirklich noch ungespeicherte Rest wird retried.
const settle = inflight.current ?? Promise.resolve()
void settle.then(() => {
if (pending.current !== null && pending.current !== last.current) {
void saveRef.current(pending.current)
}
})
}
}, [])
Die Lektion verallgemeinert sich über Autosave hinaus. Jeder Fire-and-Forget-Aufruf „auf dem Weg raus" hat eine implizite Vorbedingung: Nichts anderes spricht schon mit demselben Endpoint über dieselbe Ressource. Reacts eigene Effect-Cleanup-Doku rahmt den Cleanup als „mach rückgängig, was der Effect gemacht hat", was harmlos klingt. Es ist nicht harmlos, wenn der Effect eine Mutation ist. Jeder Cleanup, der mutiert, muss dieselbe Frage beantworten wie flush(): Läuft schon eine Mutation? Wenn ja, warten. Wenn nein, los.
Das Fire-and-Forget, das ein .catch(() => {}) brauchte
Der dritte Fund kam am selben Abend. Nachdem der Unmount-Cleanup jetzt korrekt auf das in-flight-Promise wartet, bevor er den Retry feuert, blieb eine Kante: Der Retry selbst ist Fire-and-Forget. Es gibt keine Komponente mehr, die einen Toast rendern könnte; es gibt keinen useEffect, der noch einmal laufen würde. Das Promise ist per Design verwaist.
Aber void vor einem Promise schluckt keine Rejections. Es sagt dem Compiler nur, dass der Rückgabewert absichtlich ignoriert wird. Wenn das Retry-PUT fehlschlug — weil der Finalize-Gate die Zeile bereits auf finalized umgelegt hatte oder weil das Netzwerk mitten im Teardown abriss — tauchte die Rejection als unbehandelte Promise-Rejection in der Browser-Konsole und in Sentry auf:
Unhandled Promise Rejection: Error: 409 Conflict — Assessment ist finalisiert
Dieser Eintrag ist exakt null Wert. Die Komponente ist schon weg. Die Nutzerin hat schon weggenavigiert. Niemand kann darauf reagieren, niemand kann ihn retryen, niemand kann auch nur einen Toast sehen. Er existiert allein dazu, den Error-Tracker lauter zu machen. Der Fix ist eine Zeile:
// Fire-and-Forget by design: die Komponente ist weg, niemand kann auf
// einen Fehler reagieren — schlucken statt als Unhandled Rejection werfen.
saveRef.current(pending.current).catch(() => {})
Der Präzedenzfall dazu ist MDNs Hinweis zu Unhandled Rejections: Ein Promise, das du absichtlich nicht awaitest, braucht ein explizites .catch, selbst wenn das Catch leer ist. void allein lügt gegenüber der Runtime. Das ist die Sorte TypeScript-legaler, ESLint-sauberer Code, der trotzdem falsch ist — eines der Muster, das wir in unserem Production-Failure-Playbook für KI-Agenten aufgeführt haben: die lautesten Bugs sind die, die durch jeden statischen Check schlüpfen.
Drei Regeln für einen Debounced Save, der Finalize überlebt
Die drei Fixes zusammen sind zwölf Zeilen Code. Sie lesen sich wie Trivia. Was sie in Wahrheit tun, ist einen Debounce-Hook, der nur mit sich selbst zusammenspielen musste, in einen zu verwandeln, der mit einem Lock-Endpoint komponieren muss, der einen verspäteten Write ablehnen kann, während der Client noch glaubt, mitten im Gespräch zu sein. Also — mit der Realität jeder UI, die einen Submit-Schritt hat.
Drei Regeln überleben das Rewrite, und sie verallgemeinern sich über diesen Hook hinaus:
Ein in-flight-Save auf einmal. Halte das Promise in einem Ref. Jeder Codepfad, der einen weiteren Save auslösen könnte — flush(), Unmount-Cleanup, Tastenanschlag nach einem schnellen Save — wartet auf diesen Ref, bevor er entscheidet, ob er feuert. Das ist dieselbe Disziplin wie die Request-Deduplication in TanStack Query: Du kannst nicht wissen, ob eine Mutation sicher retried werden darf, wenn du nicht weisst, ob die vorherige schon settled ist.
Cleanups, die mutieren, brauchen dieselbe Absicherung wie manuelle Flushes. Reacts Unmount-Cleanup ist kein magisch sicherer Ort, um „noch einen letzten Save" abzufeuern. Er läuft synchron im Moment des Komponenten-Teardowns, ohne die Absicherungen, die ein Foreground-Handler hat. Wenn er eine Mutation feuern kann, kann er eine Duplikat-Mutation feuern. Spiegle flush() hier oder lösche den Cleanup — lass ihn nicht als kleinere, schwächere Version des Flushes stehen.
Fire-and-Forget braucht ein explizites leeres Catch. void promise ist eine Fiktion des Typsystems. Es kompiliert. Es lintet. Es verhindert kein Unhandled-Rejection-Rauschen, wenn der zugrundeliegende Request fehlschlägt, und das Rauschen ist schlimmer, wenn der Request-Fail erwartet ist — ein Finalize-409 auf einem späten Retry, ein Netzwerk-Abort während des Unmounts —, weil es die On-Call-Ingenieurin lehrt, den Tracker zu ignorieren. Hänge .catch(() => {}) an die Feuerstelle und stoppe das Rauschen dort, wo es entsteht.
Der Finalize-Gate hat uns einen Gefallen getan. Ohne einen Server, der einen verspäteten Write ablehnen kann, war das Zwei-PUT-Fenster unsichtbar: Der zweite Write würde landen, ein No-op sein, und niemand würde es merken. Der Lock hat jede Naht in einen roten Balken in Sentry verwandelt — genau das, was ein guter Lock-Endpoint tun sollte. Wenn deine App einen Autosave hat und noch keinen Finalize-artigen Endpoint, sind die Nähte trotzdem da — du hast sie einfach noch nicht unter Last getestet. Wenn du einen baust, sparen dir die drei Regeln oben zwei Abende.
Wenn du gerade einen Editor mit Autosave und Submit-Schritt verdrahtest und ein zweites Paar Augen auf der Debounce-Komposition haben möchtest, bevor sie in Produktion geht — buche einen kostenlosen KI-Potenzial-Check — oder lies unsere Aufzeichnung zum Next.js-Worker-Split mit pg_notify für die Serverseite desselben Themas „Durability unter Redeploy".
wield · Die Recruiting‑Pipeline, die mit deinem Volumen mitskaliert.
CV‑Pipeline mit KI‑gestützter Dossier‑Generierung und Bewertung. Für Recruiter, die hundert Bewerbungen in einer Stunde sortieren — ohne Qualität zu verlieren.