Problem and Scope
Implement debounce(fn, wait, options) and throttle(fn, wait, options) for browser-facing code. The returned function must preserve the latest arguments and this, return the latest invocation result, and expose cancel() and flush(). The options are leading, trailing, and, for debounce, maxWait.
The default debounce invokes on the trailing edge only. A leading call runs at the start of a burst. If both leading and trailing are enabled, one isolated call runs once on the leading edge; a second call within the wait period creates a trailing invocation with the latest arguments. maxWait prevents a continuous event stream from postponing work forever. Throttle allows at most one invocation per wait-sized interval and supports leading and trailing behavior.
This is a frontend question because the contract is driven by browser input, scroll, resize, rendering, and component lifecycle. The same utility can run in Node.js, but changing the runtime does not change the core interview skill: translating UI timing semantics into a small state machine. The implementation uses JavaScript and O(1) auxiliary state.
What Interviewers Evaluate
The first signal is whether the candidate defines the contract before writing a timer. “Debounce waits; throttle limits” does not settle leading behavior, whether a lone leading call also trails, which arguments win, or what cleanup means. A strong answer writes a call timeline and makes those choices explicit.
The second signal is state reasoning. One timer variable is not enough once maxWait, clock movement, return values, and flush() appear. The implementation needs the last call time, last actual invocation time, pending arguments and receiver, timer handle, and most recent result. Each piece should correspond to a contract rule.
The third signal is browser judgment. Timers provide a minimum delay, not an exact deadline; long tasks and background throttling can run a callback late. Throttling scroll work with requestAnimationFrame() aligns work with paint but does not itself reduce the event rate. Sometimes scrollend, IntersectionObserver, or a framework cleanup hook removes the need for a generic timer utility.
Finally, the interviewer checks tests rather than visual intuition. Wall-clock sleeps make a slow, flaky test. A strong candidate injects or replaces time and timers, advances a fake clock, and asserts exact call sequences for boundaries, cancellation, flushing, and continuous input.
Questions to Clarify Before Answering
- What are the defaults? This answer uses debounce
{ leading: false, trailing: true }and throttle{ leading: true, trailing: true }. Different defaults change isolated-call behavior and tests. - What happens when both edges are enabled? A single call invokes only on the leading edge. A trailing invocation occurs only when another call arrives during the wait period. This avoids duplicating an isolated action.
- Which arguments and receiver does a delayed call use? It uses the latest pending call's arguments and
this. Capturing the first event would make autocomplete or resize work stale. - Can input continue forever? If yes, trailing-only debounce may never run.
maxWaitsets the maximum delay from the previous real invocation or the start of the current burst. - What should
cancel()andflush()do? Cancel removes pending work and resets burst state. Flush immediately performs an eligible trailing call and returns its result; it must not cause a later duplicate. - Is exact elapsed time required? No browser timer guarantees exact scheduling. The contract controls earliest eligibility and ordering; tests use a fake clock to remove scheduler noise.
30-Second Answer
“I would first define leading and trailing semantics with a timeline. Debounce groups a burst and normally invokes after wait milliseconds without another call. Throttle guarantees periodic progress during a burst. I keep the latest arguments and receiver, last call time, last actual invocation time, one timer, and the latest result.
On each call, I decide whether work is eligible now. Otherwise I schedule the remaining wait. The timer rechecks eligibility because a later call may have moved the trailing deadline. maxWait prevents starvation; throttle is the same state machine with maxWait fixed to wait. cancel() clears pending state, and flush() performs one pending trailing invocation. I test with a fake clock, especially calls exactly at the boundary, leading plus trailing, continuous input, cancellation, flushing, and unmount cleanup.”
Step-by-Step Deep Dive
Step 1: Turn words into timelines
Assume wait = 100 ms and calls A@0, B@40, C@90, and D@220. A trailing-only debounce produces C@190 and D@320: every call moves the quiet-period deadline, and the latest argument wins. A leading-and-trailing debounce produces A@0, C@190, and D@220. D is isolated, so it does not run again at 320.
A leading-and-trailing throttle produces at most one call per 100 ms window while preserving a final pending value. For the first burst, that means A immediately and the latest pending value, C, at the trailing boundary. Exact timestamps in a real browser may be later than the conceptual boundary, but never earlier.
This timeline reveals the state transitions: an idle call can open a burst; later calls replace pending data; a timer either invokes or reschedules; an invocation clears pending data but retains its result. Writing these transitions first prevents most off-by-one and duplicate-trailing bugs.
Step 2: Implement one explicit state machine
The implementation below follows the stated contract. shouldInvoke() handles the first call, the quiet-period boundary, a clock moving backward, and maxWait. remainingWait() chooses the earlier of the trailing and maximum-wait deadlines.
function debounce(fn, wait, options = {}) {
wait = Math.max(0, Number(wait) || 0);
const leading = options.leading === true;
const trailing = options.trailing !== false;
const hasMaxWait = Number.isFinite(options.maxWait);
const maxWait = hasMaxWait
? Math.max(wait, options.maxWait)
: 0;
let timerId;
let lastArgs;
let lastThis;
let lastCallTime;
let lastInvokeTime = 0;
let result;
function invoke(time) {
const args = lastArgs;
const receiver = lastThis;
lastArgs = undefined;
lastThis = undefined;
lastInvokeTime = time;
result = fn.apply(receiver, args);
return result;
}
function shouldInvoke(time) {
const sinceCall = time - lastCallTime;
const sinceInvoke = time - lastInvokeTime;
return lastCallTime === undefined
|| sinceCall >= wait
|| sinceCall < 0
|| (hasMaxWait && sinceInvoke >= maxWait);
}
function remainingWait(time) {
const sinceCall = time - lastCallTime;
const trailingWait = wait - sinceCall;
if (!hasMaxWait) return trailingWait;
const sinceInvoke = time - lastInvokeTime;
return Math.min(trailingWait, maxWait - sinceInvoke);
}
function trailingEdge(time) {
timerId = undefined;
if (trailing && lastArgs) return invoke(time);
lastArgs = undefined;
lastThis = undefined;
return result;
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) return trailingEdge(time);
timerId = setTimeout(timerExpired, remainingWait(time));
}
function leadingEdge(time) {
lastInvokeTime = time;
timerId = setTimeout(timerExpired, wait);
return leading ? invoke(time) : result;
}
function cancel() {
if (timerId !== undefined) clearTimeout(timerId);
timerId = undefined;
lastArgs = undefined;
lastThis = undefined;
lastCallTime = undefined;
lastInvokeTime = 0;
}
function flush() {
if (timerId === undefined) return result;
clearTimeout(timerId);
return trailingEdge(Date.now());
}
function debounced(...args) {
const time = Date.now();
const invokeNow = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (invokeNow) {
if (timerId === undefined) return leadingEdge(time);
if (hasMaxWait) {
clearTimeout(timerId);
timerId = setTimeout(timerExpired, wait);
return invoke(time);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
function throttle(fn, wait, options = {}) {
return debounce(fn, wait, {
leading: options.leading !== false,
trailing: options.trailing !== false,
maxWait: wait,
});
}The state is O(1), and every wrapper call performs O(1) work. The callback cost is outside the utility's complexity. Production code may import a maintained implementation instead; the interview value is being able to explain and test its contract.
Step 3: Explain why the timer rechecks
Suppose the first call schedules a timer for 100 ms, then a second call arrives at 90 ms. If the original timer blindly invokes at 100, the quiet period is only 10 ms. timerExpired() instead recomputes sinceCall, sees that 100 ms of silence has not elapsed, and schedules the remaining 90 ms.
Clearing and creating a timer on every event is a valid simpler trailing-only implementation. The rechecking state machine earns its complexity because it also supports leading calls, maxWait, return values, and throttle semantics. If the question asks only for basic trailing debounce, use the smaller solution and state that the richer contract would require more state.
Step 4: Prevent starvation with maxWait
An autocomplete query should usually wait for a pause. A telemetry buffer or autosave cannot wait forever while input continues. With wait = 300 ms and maxWait = 1000 ms, repeated calls every 100 ms still trigger at least once around each 1000 ms maximum boundary, subject to runtime scheduling delay.
maxWait is also the bridge to throttle. Setting it equal to wait means continuous calls cannot postpone invocation beyond one interval. Keeping one implementation avoids two timer state machines drifting in edge behavior. This derivation is an implementation choice, not the only valid definition of throttle; the contract and tests remain authoritative.
Step 5: Handle lifecycle and side effects
Delayed work can outlive the UI that scheduled it. A component must call cancel() during cleanup so an old callback does not update unmounted state, use stale props, or send a request after navigation. If the product requires committing pending text before teardown, call flush() deliberately and then clean up; do not make every unmount silently submit data.
Debouncing an asynchronous search controls request creation, not response ordering. Once a request starts, a slower old response can still overwrite a newer result. Use AbortController, a request generation, or a latest-response check in addition to debounce. Rate limiting and stale-response protection solve different failure modes.
Step 6: Choose a browser primitive by the actual job
Use debounce when only the settled value matters, such as validation after typing pauses. Use throttle when intermediate progress matters, such as periodically sampling pointer or scroll state. Use requestAnimationFrame() to align visual writes with paint, but do not claim it automatically reduces scroll event frequency. Use IntersectionObserver for threshold-based visibility and scrollend when the required event is specifically the end of scrolling.
The decision rule is behavioral: discard intermediate states, preserve periodic progress, align work with paint, or observe a browser-defined threshold. Picking a primitive by habit can either waste work or hide user-visible updates.
Step 7: Test with virtual time
Replace Date.now, setTimeout, and clearTimeout with a fake clock, or use the test runner's fake timers. Record values and virtual timestamps. Advance to just before and exactly at each deadline. Do not assert that a real 100 ms timeout fires at precisely 100 ms.
The minimum matrix covers trailing-only bursts, leading-only bursts, leading plus trailing with one and multiple calls, maxWait under continuous input, a call exactly at wait, latest arguments and receiver, result reuse, cancel before the deadline, flush before the deadline, repeated flush, zero wait, and a callback that schedules another wrapper call. For a UI integration, also test cleanup and stale network responses.
Strong Sample Answer
“I would define a timeline before coding. With a 100 ms trailing debounce, calls at 0, 40, and 90 produce one call at the first eligible time after 190 with the last arguments. A leading-and-trailing version calls at 0 and 190, but one isolated leading call does not duplicate on the trailing edge.
My state is one timer, pending arguments and receiver, last call time, last actual invocation time, and the latest result. A timer never assumes it is still eligible; it rechecks because a newer call can move the quiet deadline. maxWait adds a second deadline so continuous input cannot starve the callback. Throttle reuses the same state machine with maxWait equal to wait.
I preserve this, return the last invocation result, clear pending state in cancel(), and make flush() perform at most one trailing invocation. In a component I cancel on cleanup. For search, I separately abort or version requests because debounce cannot prevent old responses from arriving late. I verify all of this with fake time and exact call sequences, since browser timers can run late.”
Common Mistakes
- Coding before defining edge behavior → different reasonable implementations fail different tests → write defaults and a timestamped call sequence first.
- Keeping the first arguments → the delayed callback acts on stale input → replace pending arguments and receiver on every call.
- Always firing a trailing call after a leading call → one click causes two actions → trail only when another call became pending during the interval.
- Resetting forever without
maxWait→ a continuous stream can starve autosave or batching → add a maximum deadline when periodic progress is required. - Treating timer delay as exact → long tasks and runtime throttling break timestamp assertions → treat it as earliest eligibility and test with virtual time.
- Using
requestAnimationFrame()as a generic throttle → it can run at the same cadence as scroll events → use it for paint alignment and measure a separate interval when rate reduction is required. - Forgetting cleanup → delayed work runs after navigation or unmount → cancel during lifecycle cleanup or explicitly flush when product semantics require it.
- Assuming debounce prevents stale search results → already-started requests can resolve out of order → combine it with cancellation or latest-request validation.
- Building the full state machine for a basic prompt → unnecessary code increases bug surface → implement the smallest stated contract, then describe extensions.
Follow-up Questions and Answers
Follow-up 1: What happens when a call arrives exactly at the wait boundary?
Define the ordering model. In a deterministic test, if the old timer task executes before the new call task at the same virtual timestamp, the old burst may trail and the new call begins another burst. If the new call is processed first, it can update pending state before the timer rechecks. Browser task queues do not make simultaneous external events magically atomic. Tests must schedule a specific order and the implementation must be internally consistent.
Follow-up 2: Why not implement throttle with setInterval?
An interval ticks even when there is no pending work unless additional state suppresses it. It also makes leading, final trailing value, cancellation, and restart after idle harder to reason about. A one-shot timer scheduled from real demand gives explicit control over the next boundary. setInterval can work with a precise contract, but it is not simpler once all required semantics are included.
Follow-up 3: Should flush run when trailing is disabled?
No pending trailing work is eligible, so flush() returns the most recent invocation result without calling fn. This follows the same trailing gate as timer expiry. If a product needs “force execution regardless of options,” that is a different API and should receive a different name and tests.
Follow-up 4: How would you test clock rollback?
Inject a clock and move its value below lastCallTime. The sinceCall < 0 branch treats that state as eligible rather than scheduling a huge or negative remainder. A monotonic clock is preferable where available, but browser utility contracts often use the runtime clock indirectly; the defensive branch prevents a stuck wrapper.
Follow-up 5: When should a project use Lodash instead of this implementation?
Use the maintained library when its documented semantics, bundle strategy, and project dependency policy fit. A homegrown utility needs its own compatibility tests, review, and ownership. Implementing it in an interview demonstrates reasoning; it does not prove that duplicating a mature dependency is the best production decision.