Back to the crumb
The lesson
reactnextChewy~25 min

Why the autosave couldn't wait

What you saw

Two symptoms that looked unrelated. The save tally climbed by roughly one per keystroke, as if the debounce weren't there at all. And the server's copy of the draft ran exactly one keystroke behind — type fast, reload, and the last letter was gone. Waiting didn't help; the missing letter never arrived.

What was actually happening

The debounce was fine. There were just several hundred of them.

A controlled textarea re-renders the component on every keystroke — that's how controlled inputs work. And a React component is just a function: when it re-renders, its whole body runs again, and everything defined in that body is created again. Including this line:

const queueSave = debounce(saveDraft, SAVE_DELAY_MS)

A debounce's entire power lives in its private timer variable — its memory of "I've been called recently, hold on." That memory belongs to one specific instance. Recreate the instance and the memory is gone.

So the timeline for typing "rye" was:

  1. Keystroke r → calls render #1's debounce → it schedules a timer. Re-render.
  2. Keystroke y → calls render #2's brand-new debounce — which has never been called, has nothing to cancel — so it schedules a second timer. Re-render.
  3. Keystroke e → render #3's debounce, third timer.

Nobody holds anyone else's timer handle, nothing cancels anything, all three fire. One save per keystroke, always. The debounce utility passed every isolated test because in a plain script you call the same instance repeatedly. In the component, no instance was ever called twice.

The missing last letter is the same bug wearing a different hat. saveDraft reads notes from its own render's closure — a snapshot. The debounce invoked by keystroke k was created during the render before keystroke k's state existed, so save k writes the text as of keystroke k−1. Every payload is one keystroke stale, including the last one. That's a stale closure, and it's why the flipbook in /api/drafts/history is shifted by one.

The fix

The idiomatic React move is to debounce the value, not the function:

useEffect(() => {
  if (notes === null) return
  const t = setTimeout(() => save(notes), SAVE_DELAY_MS)
  return () => clearTimeout(t)
}, [notes])

This is a debounce built from React's own primitives. Every keystroke changes notes, the effect re-runs, and the cleanup — React's built-in "cancel the previous one" — clears the old timer before the new one starts. One timer alive at a time, and the effect that finally fires closes over the latest notes. One save, correct payload, nothing extra to memoize.

The other honest fix is to make the debounced function genuinely stable (create it once with useRef or useMemo(..., [])) and feed it fresh text — either as an argument (queueSave(e.target.value)) or via a ref the callback reads at fire time.

Beware the half-version of that fix, because it's a trap: stabilizing the debounce while the callback still reads notes from closure stops the spam — one save, looks perfect — but the frozen first-render closure writes the original seeded text, silently discarding your edit. That's a worse bug than the one you started with, wearing a green checkmark. (It's also why the grading checks payload content, not just save counts.)

And the tempting non-fix: raising SAVE_DELAY_MS. Forty keystrokes still mint forty debounce instances and forty timers; a longer delay just means the spam lands later. If a "fix" changes when the symptom happens instead of whether, you've patched the crust, not the crumb.

The bug class

This is a referential-identity bug: anything stateful created in a component body — debounces, throttles, caches, event emitters, socket clients, new AbortController() handed to long-lived code — is reborn on every render, and its internal state dies with each rebirth. Its constant companion is the stale closure: functions that outlive their render but still see that render's world.

How to spot it in the wild: any debounce( or throttle( directly inside a component body is guilty until proven memoized —

grep -rn "debounce(\|throttle(" app/ src/ | grep -v node_modules

— and any callback that fires later (timer, subscription, socket handler) deserves the question "which render's state does this see?"

It's everywhere in AI-generated React for a simple reason: the assistant learned debounce from a decade of vanilla-JS tutorials where creating it once at module scope was automatic. Pasted into a component, the same five lines compile clean, demo fine if you type slowly, and fall apart exactly the way this card did.