The Debounce That Dropped the Last Keystroke
Fast typists submitted search queries missing their final character, and only fast typists, because the trailing edge timer was cancelled on unmount before…
The complaint was odd enough to be true: users who typed quickly and hit enter immediately got results for a query missing its last character, "wireless mouse" returned "wireless mous", while users who typed slowly, or paused before submitting, were fine. The bug selected for speed, which is the signature of a timer race, and the timer was a debounce, the most innocent construct in the codebase.
This is the bug hunt for a trailing edge debounce that lost the final event, not in the debouncing itself, but in the teardown, because the last keystroke was still waiting out its delay when the component that owned the timer went away, and the cleanup cancelled the very callback that held the user's input.
This was a React 19.1 search box with a debounced onChange, and the loss happened on the submit path, where the debounce and the submit raced for the same value.
The symptom, precisely
Two ways to lose the keystroke presented, and the reproduction distinguished them. If the user typed and waited, the debounced value arrived and submit used it, correct. If the user typed and submitted within the debounce window, the submit read the last committed debounced value, which lacked the newest characters, and the pending timer, holding the true value, was then cancelled by unmount or by the submit's navigation, so the truth never landed. The selectivity by typing speed is exact: only submissions inside the window lose, and the window is a few hundred milliseconds, which is precisely the fast typist's rhythm.
The hypotheses that died
The backend truncated the query
First I checked the server logs, which showed the truncated query arriving, so the loss was client side, before the network. It died because the wire carried the bug.
The input was controlled wrong
Next I assumed a controlled input dropping the keystroke at the state layer. Typing into the box showed the correct full text on screen, so the input state had the character. It died because the visible state and the submitted state diverged, which pointed at the derived, debounced value, not the input.
The enter key handler was stale
Then I suspected a stale closure on the submit handler reading an old value. The handler read the current debounced value, which was correct behaviour for a debounced value. The defect was that the debounced value was, by design, behind, and the submit trusted it as if it were current.
The breakthrough
The diagnosis is that the debounce introduced a second, delayed copy of the truth, and the submit path had two sources that disagreed for the length of the window: the raw input, current, and the debounced value, stale by design. Any reader that chooses the debounced value inside the window reads stale data, and any teardown that cancels the pending timer inside the window destroys the only copy of the new data that would have reconciled them. The fast submit did both: it read the stale copy, and the navigation cancelled the pending timer, deleting the truth.
This is the cache twin of the bug: the debounced value is a cache of the input with a write delay, and reading it before the delay is reading a stale cache, per cache invalidation and stale data bugs, and cancelling the timer is deleting the pending write, which is the loss.
The fixes
Flush on submit. The submit path must not read the debounced value. It must flush the pending timer, running the deferred callback immediately, and read the resulting value, so the pending write lands before it is read. A debounce that exposes a flush turns the race into a sequence, and the sequence is correct by construction.
Never cancel the pending write on teardown when the value matters. The unmount cleanup cancelled the timer to avoid a setState after unmount, a correct instinct for a dead component, but the value was needed by the submit that caused the navigation. The fix separates the two concerns: the timer that updates component state is cancelled on unmount, but the timer that feeds the submit is flushed, not cancelled, because its payload is data, not a render.
Make the raw input the source of truth for the submit. The cleanest fix removes the race entirely: the submit reads the raw input value, which is current by definition, and the debounce is used only for the live search it was built for. The debounced value then has one reader, the live query, and the submit has one source, the input, and two sources that cannot disagree cannot race.
The test that catches it
The regression test types the query without a pause and submits immediately, asserting the submitted query equals the full string, and a second test unmounts mid window and asserts the flushed value is delivered to its consumer. The tests fail on the original code in milliseconds, and they encode the property the bug violated: no user input is lost between the keystroke and the read, regardless of timing, which is the invariant a debounce must preserve to be safe.
What I would do differently
I would have named the debounce's contract at the time it was written: it is a delay, and any reader inside the delay reads stale, and any teardown inside the delay must decide, deliberately, whether the pending value is flushed or discarded. The bug was not the debounce but the absence of that decision, defaulted to discard by a cleanup that had never considered that the pending value was someone's input.
I would also have treated the fast typist as the population, because the debounce window is measured against human speed, and the users fastest relative to the window are the ones who live inside it, which is the sampling lesson from the hydration mismatch that only happened with an ad blocker: the reproduction matrix must include the fast path.
What I now do
A trailing debounce is a cache with a write delay, and reading it inside the window reads stale while cancelling its timer deletes the pending write, so a fast submit that reads the debounced value and then navigates loses the last keystroke twice over. Flush on submit, keep the raw input as the submit's source, and let teardown discard only values nobody needs.
The dropped character was never a typing bug. It was a timer that owned the truth for three hundred milliseconds and was cancelled before it could hand it over. Name that one property in review and this class of bug gets hard to ship.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud