Why does this debounce helper still fire twice on a quick submit?

Hey everyone, I’m wiring up a small form and trying to debounce a save call so impatient double-clicks do less damage, but right now a fast second submit still slips through and creates duplicate writes.

function debounce(fn, wait) {
  let timer = null;
  return (...args) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(args), wait);
  };
}

const save = (id) => console.log('saving', id);
const debouncedSave = debounce(save, 300);

debouncedSave(42);
debouncedSave(42);

What am I missing here if I want only one save to happen for a burst of calls without quietly breaking the arguments passed in?

Sarah

@sarah_connor Your debounce helper is currently doing “call fn once with one array argument after the last timeout,” not “call fn once with the original arguments.” So the burst is being collapsed, but the argument forwarding is slightly wrong.

.args collects arguments into an array, and fn(args) passes that array as one value. If you want debounce behavior without changing how save receives its parameters, you need to spread them back out on the call.

The smallest fix is this:

function debounce(fn, wait) {
  let timer = null;

  return (.args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(.args), wait);
  };
}

Tiny version of the same point:

fn(args)    // one array argument
fn(.args) // original arguments restored

In clearTimeout(timer) and fn(.args), those two lines are what make the behavior correct:

  • clearTimeout(timer) cancels the previously scheduled submit in the same click burst.
  • fn(.args) ensures the last call runs with the original argument list instead of a wrapped array.

That gives you one trailing save with the right parameters.

Using your debouncedSave(42) example, here’s the runnable version:

function debounce(fn, wait) {
  let timer = null;

  return (.args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(.args), wait);
  };
}

const save = (id) => console.log('saving', id);
const debouncedSave = debounce(save, 300);

debouncedSave(42);
debouncedSave(42);

// after ~300ms:
// saving 42

If this is protecting real writes, also disable submit while the request is in flight, since debounce only merges client-side bursts.

MechaPrime :slightly_smiling_face:

@MechaPrime already fixed the fn(.args) part, but his last line matters more for real forms since debounce only coalesces clicks and won’t stop two separate requests if the first submit has already left the browser.

Hari :smiling_face_with_sunglasses:

Hari

@HariSeldon the “first submit has already left the browser” bit is the real bug here-double-tap Enter can queue a second POST after the debounce window, so add an isSubmitting guard or disable the button until the promise settles.

WaffleFries