Kata: debounce
Write the state, not just the timer: every exit (fire, cancel, flush) leaves timer null and nothing saved.
Predict first
Before you read: predict
ExamHall's AnswerEditor autosaves a candidate's short answer. The team merged this kata answer and created the autosave with debounce(saveAnswer, 800, { leading: true }), so that the first key saves at once:
A candidate types one key, M, at t = 0 and nothing more before t = 3000 ms.
Which calls of saveAnswer happen?
export function debounce(fn, wait, { leading = false, trailing = true } = {}) { let timer = null; let pending = null; function fire() { timer = null; if (trailing && pending) { const { self, args } = pending; pending = null; fn.apply(self, args); } } return function (...args) { pending = { self: this, args }; clearTimeout(timer); timer = setTimeout(fire, wait); const first = timer === null; if (leading && first) { pending = null; fn.apply(this, args); } };}The 30-second version
A kata debounce keeps three things in its closure: the pending timer, the latest call's arguments and its this. Each call saves them and replaces the timer. When the timer fires, timer goes back to null, and a saved call runs once and is forgotten. cancel(), flush() and leading must leave that same clean state.
Write the state, not just the timer: every exit (fire, cancel, flush) leaves timer null and nothing saved.
How it works
Step 1 of 10
cancel, flush or leading is wanted. Then: one timer, reset per call, latest arguments and this.Analogy: A trophy engraver's order slip (crafts)
A club secretary keeps phoning corrections to the winner's name on a trophy. The engraver works from one order slip and a reminder clipped above her bench.
| At the engraver's bench | In the kata |
|---|---|
| one slip per order: the newest wording and which trophy it goes on | lastArgs and lastThis, one set per debounce() call (steps 2–3) |
| each call: take the reminder down, clip up a fresh one for an hour later | clearTimeout, then setTimeout (step 4) |
| an hour with no call: take the reminder down, engrave the slip's wording on that trophy, bin the slip | timer = null, then run and forget (steps 5–6) |
| "scrap the order": take the reminder down and empty the slip | cancel() (step 7) |
| "engrave it now": take the reminder down, then engrave | flush() (step 8) |
| a crossed-out reminder left on its clip: the next call looks like part of the old order | an id left in timer after clearTimeout (step 7) |
| a rush order: engrave the first wording at once; at the hour, engrave again only if a correction came after | leading (step 9) |
In real terms: a debounced function is three variables and a few rules for when each one is set and when it goes back to empty.
Breaks down when: re-engraving costs metal, so an engraver hesitates; a run costs nothing, and the code never hesitates. The slip also holds one trophy, while lastThis is whatever object made the last call.
Contrasting case: an engraver who takes no slip and engraves each phone call's wording as it arrives. Nothing is saved or merged, so there is nothing to reset: that is a plain function call, not a debounce.
Examples, run for real
4.1 ExamHall EXH-2760: the test list catches the two common mutants
A pairing-round draft of the AnswerEditor's autosave saved the first keystroke's text (EXH-2760). MR !3060 replaced it with the reference answer and added the test list to the repository. The list runs on the reference and on the curriculum's two common mutants: M1 drops clearTimeout(timer), and M2 saves the arguments only when a burst starts.
4.1 reference.mjs 18 of 18 pass (7 core, 11 ladder)
4.1 M1, no clearTimeout 15 of 18 pass (7 core, 11 ladder) · red: "a burst runs fn once, wait ms after its last call", "leading: a burst runs at its first call and wait ms after its last", "leading: a call after a burst's trailing run runs at once"
4.1 M2, first call's arguments 14 of 18 pass (7 core, 11 ladder) · red: "fn gets the last call's arguments", "leading: a burst runs at its first call and wait ms after its last", "leading: after flush(), the next call runs at once", "leading: a call after a burst's trailing run runs at once"
4.1 EXH-2760 the M2 draft as autosave, keys D, De, Dem at 0/300/600: saves 1400 ms "D"
4.1 MR !3060 autosave, keys at 0/300/600, then 1900, unmount at 2000: saves 1400 ms "Dem"; nothing after the unmount
M1 leaves one timer per call (step 4) and fails the burst test; M2 keeps the first call's arguments (step 3) and fails the arguments test. The unmount calls cancel(), so a closed question saves nothing (step 7).
4.2 Ticketing: flush and cancel on the seat search
The box office's seat search runs the reference as debounce(findSeats, 300). Enter calls flush(), and Esc calls cancel():
4.2 seat search: "A" 0, "A1" 150, Enter 250, "B" 1000, Esc 1100, "C" 1500 -> searches 250 ms "A1" · 1800 ms "C"
Enter searched A1 at once and ended that burst (step 8). Esc dropped the pending B (step 7), and C started a fresh burst (step 6).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office's seat map shows a seat's price once the pointer rests on it. A candidate's kata answer has one line left to write:
The page builds new SeatMap('ev-9', prices), and the pointer rests on seats A1, A2 and A3 at t = 0, 100 and 200 ms, each time calling map.preview(seat).
Which line in the gap gives the price service one lookup, {"event":"ev-9","seat":"A3"}, at 500 ms, and no other?
export function debounce(fn, wait) { let timer = null; function debounced(...args) { clearTimeout(timer); /* the missing line */ } return debounced;} export class SeatMap { constructor(eventId, prices) { this.eventId = eventId; this.prices = prices; this.preview = debounce(this.showPrice, 300); } showPrice(seat) { this.prices.lookup({ event: this.eventId, seat }); }}Question 2 of 8
ExamHall's AnswerEditor calls this.autosave.cancel() when a question unmounts, so a closed exam never saves. A candidate's kata answer is complete apart from the body of cancel:
The answer has no flush yet, so the 13 tests of the team's list that do not call flush are run; 3 of them call cancel().
Which body passes all 13?
export function debounce(fn, wait, { leading = false, trailing = true } = {}) { let timer = null; let lastArgs = null; let lastThis = null; function run() { const args = lastArgs; const self = lastThis; lastArgs = lastThis = null; fn.apply(self, args); } function onQuiet() { timer = null; if (trailing && lastArgs) run(); } function debounced(...args) { lastArgs = args; lastThis = this; const startsBurst = timer === null; clearTimeout(timer); timer = setTimeout(onQuiet, wait); if (leading && startsBurst) run(); } debounced.cancel = function () { /* the body */ }; return debounced;}Question 3 of 8
A chat app posts each message draft for the other person's preview through postPreview = debounce(sendPreview, 1000), and Enter calls postPreview.flush(). Its kata answer:
A user types h at t = 0 and he at 300 ms, presses Enter at 500 ms, then types x at 700 ms, and nothing more before t = 3000 ms.
Which calls of sendPreview happen?
export function debounce(fn, wait) { let timer = null; let lastArgs = null; let lastThis = null; function onQuiet() { timer = null; if (lastArgs) { const args = lastArgs; const self = lastThis; lastArgs = lastThis = null; fn.apply(self, args); } } function debounced(...args) { lastArgs = args; lastThis = this; clearTimeout(timer); timer = setTimeout(onQuiet, wait); } debounced.flush = () => { if (timer === null) return; onQuiet(); }; return debounced;}Question 4 of 8
A video app's Like button sends a like through like = debounce(sendLike, 1000, { leading: true }), so that a tap sends at once. With this kata answer, one tap sends two likes:
A viewer taps at t = 0, 3000, 6000 and 6300 ms.
Which one change makes sendLike run at 0, 3000, 6000 and 7300 ms, and at no other time?
export function debounce(fn, wait, { leading = false, trailing = true } = {}) { let timer = null; let lastArgs = null; let lastThis = null; function run() { const args = lastArgs; const self = lastThis; fn.apply(self, args); } function onQuiet() { timer = null; if (trailing && lastArgs) run(); } function debounced(...args) { lastArgs = args; lastThis = this; const startsBurst = timer === null; clearTimeout(timer); timer = setTimeout(onQuiet, wait); if (leading && startsBurst) run(); } return debounced;}Question 5 of 8
the interviewer says: "Implement debounce(fn, wait): fn runs once, wait ms after the last call, with that call's arguments and this. Then add cancel()." You have 15 minutes and a plain editor, and the AnswerEditor's autosave (800 ms) is the use case.
Before you type: say your test list out loud.
Name at least six tests, each with its calls and what it expects.
Question 6 of 8
the interviewer says: "Implement debounce(fn, wait): fn runs once, wait ms after the last call, with that call's arguments and this. Then add cancel()." You have 15 minutes and a plain editor, and the AnswerEditor's autosave (800 ms) is the use case.
Now write it, in 15 minutes, from a blank file that exports debounce.
Question 7 of 8
A payments ledger's Refresh button reloads the balance through refresh = debounce(loadBalance, 1000, { leading: true }). Its kata answer:
A user taps at t = 0, 3000, 6000, 6300 and 9000 ms; the balance should load at 0, 3000, 6000, 7300 and 9000 ms and at no other time. The tap at 0 loads at once, but the tap at 3000 loads only at 4000 ms. onQuiet is missing one line.
Which?
export function debounce(fn, wait, { leading = false, trailing = true } = {}) { let timer = null; let lastArgs = null; let lastThis = null; function run() { const args = lastArgs; const self = lastThis; lastArgs = lastThis = null; fn.apply(self, args); } function onQuiet() { if (trailing && lastArgs) run(); } function debounced(...args) { lastArgs = args; lastThis = this; const startsBurst = timer === null; clearTimeout(timer); timer = setTimeout(onQuiet, wait); if (leading && startsBurst) run(); } return debounced;}Question 8 of 8
A shop's search box runs search = debounce(runSearch, 400, { leading: true }), and Enter calls search.flush(). Its kata answer:
The answer has no cancel yet, so the 14 tests of the team's list that do not call cancel are run, and exactly one is red: leading: after flush(), the next call runs at once.
Which body for flush makes all 14 pass?
export function debounce(fn, wait, { leading = false, trailing = true } = {}) { let timer = null; let lastArgs = null; let lastThis = null; function run() { const args = lastArgs; const self = lastThis; lastArgs = lastThis = null; fn.apply(self, args); } function onQuiet() { timer = null; if (trailing && lastArgs) run(); } function debounced(...args) { lastArgs = args; lastThis = this; const startsBurst = timer === null; clearTimeout(timer); timer = setTimeout(onQuiet, wait); if (leading && startsBurst) run(); } debounced.flush = () => { if (lastArgs) run(); }; return debounced;}Go deeper
Pro tips
- Say the invariant out loud before typing: "one timer, reset on every call, fire with the latest args". When: the first minute of the kata, after the clarifying questions (step 1). It rules out both the missing-
clearTimeoutmistake (mutant M1) and the stale-arguments mistake (M2). Verify: each mutant fails its own test in the list, and the reference passes all 18. (The curriculum row's tip, [SELF].) - Run your own answer against the list before you call it done:
KATA_IMPL=./my-debounce.mjs node --test kata-core.test.mjs. When: practising the kata at home, against the same tests the reference passes (step 10). Verify (RAN,tips.mjs):tip 2 KATA_IMPL=./reference-core.mjs: exit 0 · tests 7 · pass 7 · fail 0 tip 2 KATA_IMPL=<temp>/m1-debounce.mjs (no clearTimeout): exit 1 · tests 7 · pass 5 · fail 2 · red: "a burst runs fn once, wait ms after its last call", "fn gets the last call's arguments"
Source
Vendor and guide pages change, so their claims are labelled as of 2026-10-04; every behaviour this lesson grades is executed on Node 24.19.0.
- Front End Interview Handbook, JavaScript coding (the curriculum row's first source), https://www.frontendinterviewhandbook.com/coding/javascript-utility-function (
feih-javascript-coding.txt, page "Last updated on Aug 13, 2026"). It lists debounce among the Lodash functions asked in interviews, says candidates take "around 10-15 minutes for a basic question" (the time box, step 1), and advises writing test cases (step 10). As of 2026-10-04. - Lodash 4.17.15 documentation,
_.debounce(the row's second source), https://lodash.com/docs/4.17.15#debounce. lodash.com refused connections on 2026-10-04 (curl exit 28, twice; the R2 fix-brief writer's retry at 05:58:32Z also gavecurl: (28)), so the page was read from the unmodified Internet Archive capture (id_, Memento-Datetime 2025-12-05T10:14:29Z) of that URL, the treatment_fix-brief-r1.mdruling 1 accepts for a named primary page (lodash-debounce.txt, header linesViaandMemento-Datetime). It namescancelandflush, saysfunc"is invoked with the last arguments provided", and says that with both edges on,funcruns at the trailing edge "only if the debounced function is invoked more than once during the wait timeout" (steps 7–9). This lesson's reference is its own code, not Lodash's. - MDN, Window: setTimeout() method (the row's third source), https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout (
mdn-settimeout.txt). The returned "timeout ID … can be passed to clearTimeout() to cancel the timer" (steps 4, 7), and a non-arrow callback is called withglobalThisasthisin browsers (step 3). As of 2026-10-04. - Node.js v24 documentation, Test runner,
Class: MockTimers, https://nodejs.org/docs/latest-v24.x/api/test.html (node-test-mocktimers.txt):mock.timers.enable()andtick()(step 10). As of 2026-10-04.
Related lessons
Checked against the tools and docs as of 2026-10-04.