Chapter on Software

Debounce, Rebuilt,
a small function with a lot to say

How a six-line utility teaches you to think about time, state, and the shape of software
Tech · 1 tháng 4, 2026 · 5 phút đọc

A debounce function is small enough to write from memory. Most developers have. Most developers have also gotten it subtly wrong — not because the logic is hard, but because the idea underneath is slippery.

This is an attempt to rebuild it from first principles, slowly, the way you would if you were explaining it to someone who had never seen it before — and to notice, along the way, what it teaches about writing software in general.

§

The Problem

You have a function. A user calls it many times in rapid succession. You want the function to run only once — specifically, once the user has stopped calling it.

The naive implementation just calls the function directly. The debounced version says: wait. If you're called again before a timeout expires, reset the clock.

§

The First Implementation

Here is the version most developers reach for first:

function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null;
 
  return function (...args: Parameters<T>) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

This works. For most use cases, this is enough. But there are two things this version does not tell you.

2.0

What it Does Not Say

The first silence: this function is stateful. The timer variable lives between calls. It is not a pure function. Every time you call the returned closure, it reaches into that shared state.

The second silence: the returned function has no return value. The original fn might return something meaningful. The debounced wrapper discards it silently.

§

Thinking in States

Here is a more useful way to think about debounce. The function is not "a timer" — it is a small state machine with two states:

Plate IDebounce as a state machine — four transitions

When you see it this way, the implementation almost writes itself. The question is not "how do I set a timer" — it is "how do I track which state I am in, and what happens on each transition."

§

A Better Version

function debounce<T extends (...args: unknown[]) => unknown>(
  fn: T,
  delay: number
): {
  call: (...args: Parameters<T>) => void;
  flush: () => void;
  cancel: () => void;
} {
  let timer: ReturnType<typeof setTimeout> | null = null;
  let lastArgs: Parameters<T> | null = null;
 
  function fire() {
    if (lastArgs) fn(...lastArgs);
    timer = null;
    lastArgs = null;
  }
 
  return {
    call(...args: Parameters<T>) {
      lastArgs = args;
      if (timer) clearTimeout(timer);
      timer = setTimeout(fire, delay);
    },
    flush() {
      if (timer) { clearTimeout(timer); fire(); }
    },
    cancel() {
      if (timer) clearTimeout(timer);
      timer = null;
      lastArgs = null;
    },
  };
}

The highlighted lines are the state. Everything else is transitions.

§

In Practice

Here is how you use the stateful version in a real component:

import { useEffect, useRef } from 'react';
 
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
  const debounced = useRef(debounce(onSearch, 300));
 
  useEffect(() => {
    return () => debounced.current.cancel();
  }, []);
 
  return (
    <input
      type="search"
      onChange={(e) => debounced.current.call(e.target.value)}
    />
  );
}

And here is what happens when you run it:

dev:~/project
$ npx tsx SearchBox.tsx --watch
[12:04:01] Input: "d"
[12:04:01] Input: "de"
[12:04:01] Input: "deb"
[12:04:01] Input: "debo"
[12:04:01] Input: "debou"
[12:04:02] Fired: "debounce"
[12:04:02] API call: GET /search?q=debounce → 200 OK (48ms)
§

What This Teaches

A debounce function is a lesson in disguise. It teaches:

Time is state. The timer is not just a delay — it is a record of what the function intends to do next. Clearing it is a decision, not just a cleanup.

Naming is thinking. The difference between clearTimeout(timer) and cancel() is not syntax — it is clarity about what the action means in the domain of the problem.

Small functions have surfaces. The version with flush and cancel has a surface area. You can test its edges. The one-liner does not. This is not always an advantage, but it is always a choice.

Debounce is not a delay. It is a promise: I will act, but only when you are done asking me to.

The pendulum in the previous essay and the debounce function in this one are, in some sense, the same thing: a system that waits to act until the world has settled. The pendulum waits for gravity. The function waits for silence.

Both are worth watching.

A quiet word

What did you take away from this?

Not published. Never shown to other readers.
KếtĐọc chậm