Skip to content

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?

Code for this question
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);    }  };}
Pick one

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

Ask, then say the invariant. Ask whether 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?

Code for this question
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 });  }}
Pick one

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?

Code for this question
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;}
Pick one

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?

Code for this question
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;}
Pick one

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?

Code for this question
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;}
Pick one

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?

Code for this question
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;}
Pick one

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?

Code for this question
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;}
Pick one

Go deeper

Pro tips
  1. 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-clearTimeout mistake (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].)
  2. 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 gave curl: (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.md ruling 1 accepts for a named primary page (lodash-debounce.txt, header lines Via and Memento-Datetime). It names cancel and flush, says func "is invoked with the last arguments provided", and says that with both edges on, func runs 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 with globalThis as this in 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() and tick() (step 10). As of 2026-10-04.

Checked against the tools and docs as of 2026-10-04.