Some events fire many times in quick succession: input fires every time the text in a box changes, and resize fires over and over while a window is resized. Often only the end result matters. Debouncing a function makes it wait for a pause. However often it's called, it runs only after the calls have stopped for a set time, called the wait, and then it runs once, with the arguments of the most recent call. Every new call during the wait starts the wait over. Calls that come closer together than the wait form a burst.
Picture a lift that waits for people to stop arriving. Each time someone steps in, the doors stay open a few seconds longer. The lift leaves only once nobody has stepped in for that long, and it makes one trip, however many people got in.
Say search(text) fetches results for a search box. The box's input handler doesn't call search directly: it calls a debounced version of it, with a wait of 300 ms, passing the text. The user types cat quickly, pauses, then adds an s:
| Time | Debounced call | What happens |
|---|---|---|
| 0 ms | "c" | A burst starts. If no other call comes, search will run at 300 ms. |
| 100 ms | "ca" | A call during the wait starts it over: now search will run at 400 ms. |
| 250 ms | "cat" | The wait starts over again: 550 ms. |
| 550 ms | None | 300 ms have passed without a call, so the burst ends and search("cat") runs. |
| 900 ms | "cats" | A new burst starts: search will run at 1200 ms. |
| 1200 ms | None | search("cats") runs. |
Four calls, two searches, each for the text the user had stopped at. No request is ever sent for c or ca. The cost is a delay: search runs 300 ms after the last key press, and as long as calls keep coming less than 300 ms apart, it doesn't run at all.
Running at the end of a burst, as here, is called running on the trailing edge. A debounce can instead, or also, run on the leading edge: right away, on the call that starts a burst. Lodash's _.debounce has leading and trailing options for this, and with both on, a burst of one call runs once, not twice. Its maxWait option is the longest a run can be put off: once a burst has gone on that long, the function runs then, with the latest arguments, without waiting for the pause. That way an autosave still saves now and then while someone types without stopping. Here's the first burst above under each setting:
| Setting | The function runs | With |
|---|---|---|
| Trailing edge (the usual default) | At 550 ms | "cat" |
| Leading edge only | At 0 ms | "c" |
| Both edges | At 0 ms and at 550 ms | "c", then "cat" |
| Both edges, if only the call at 0 ms is made | At 0 ms only | "c" |
Trailing edge, with a maxWait of 400 ms | At 400 ms, not 550 ms | "cat" |
Debouncing is often confused with throttling. A throttle runs the function at most once per interval while the calls keep coming; a debounce waits until they stop. Debounce when only the final value matters: search as you type, autosave, checking a field once the user stops typing, or redoing a layout once a window stops resizing. Debounce helpers often also have cancel(), which drops a pending run, and flush(), which runs it right away; Lodash's _.debounce has both.
A race condition is a bug where the result depends on the order in which overlapping operations happen to run or finish. The code works when things happen in one order and goes wrong in another, and since the order depends on timing, such as how fast the network is at that moment, the bug can come and go.
JavaScript runs one piece of code at a time, but async operations still overlap: while a request is on its way, or a function is paused at an await, other code runs. Two cases come up again and again. The first is a stale response, an answer to an old request that replaces a newer one. You type j, then js, into a search box that sends a request for each, and the request for j happens to be slow:
| Time | What happens | Results on the screen |
|---|---|---|
| 0 ms | You type j, and the request for j is sent. | None yet |
| 200 ms | You type js, and the request for js is sent. | None yet |
| 500 ms | The response for js arrives and is shown. | For js |
| 900 ms | The response for j arrives late and is shown. | For j, while the box says js |
Responses don't have to arrive in the order their requests were sent. Only the latest request matches what's in the box, so only its response should be used. The answer to an older request is out of date as soon as a newer request is made, and has to be ignored or cancelled. You meet this in search boxes, autocomplete, and pages that load data for whichever tab or item you clicked last.
The second is a lost update. Two async tasks read the same value, each pauses at an await, and each then writes back a new value based on what it read. Here, two withdrawals, A of 30 and B of 50, run against a balance of 100, and each awaits a slow save between reading the balance and writing it:
| Step | What happens | balance afterwards |
|---|---|---|
| 1 | A reads balance, 100, and pauses at its await. | 100 |
| 2 | B reads balance. A hasn't written yet, so B also gets 100. It pauses too. | 100 |
| 3 | A resumes and writes 100 - 30. | 70 |
| 4 | B resumes and writes 100 - 50, from the value it read in step 2. | 50 |
Both withdrawals ran, yet the balance is 50 instead of 20: A's update was lost. Each step is fine on its own; the bug is in how they interleave. Had B read the balance after A had written it, B would have read 70 and written 20.
Cancelling async work means telling something you started, such as a request, that you no longer need its result, so it can stop. You want that whenever the result has become useless: the user typed a new search, closed the dialog, or left the page. Stopping saves bandwidth, battery, and server time, and keeps an out-of-date result from turning up later.
A promise can't be stopped from outside. It's only a placeholder for a result: it has no cancel method, and the work behind it carries on whether or not anyone is still waiting for it. So cancelling is a request, not a command. You give the work a way to hear that it should stop, and the work stops itself. Work that never listens carries on regardless.
The standard way to ask is AbortController, built into browsers and Node.js. Each controller comes with a signal, an AbortSignal. You keep the controller and hand the signal to the work; calling controller.abort() then tells everything holding the signal to stop. fetch takes a signal as an option, and aborting it cancels the request:
const controller = new AbortController();
fetch("/api/search?q=shoes", { signal: controller.signal })
.then((response) => response.json())
.then((results) => console.log(results)) // never runs
.catch((error) => console.log(error.name)); // "AbortError"
controller.abort(); // the user typed something else
console.log(controller.signal.aborted); // true
When a signal is aborted, three things happen: signal.aborted turns true, signal.reason holds the reason passed to abort(reason), and the signal fires an abort event, so work that's waiting can react straight away. A signal is aborted only once: calling abort again does nothing.
const controller = new AbortController();
const { signal } = controller;
signal.addEventListener("abort", () => console.log("abort event:", signal.reason));
signal.aborted; // false
controller.abort("The dialog was closed"); // logs "abort event: The dialog was closed"
signal.aborted; // true
signal.reason; // "The dialog was closed"
controller.abort("Again"); // does nothing: no second event, and the reason stays the same
By convention, cancelled work rejects with an error whose name is "AbortError". Calling abort() without a reason sets signal.reason to a DOMException with that name (DOMException is the error type web APIs use), and a fetch aborted that way rejects with an AbortError too. Callers check error.name === "AbortError" to tell a cancellation, which they asked for and usually ignore, from a real failure.
Signals turn up across the platform. addEventListener takes a signal option, and aborting that signal removes the listener. AbortSignal.timeout(ms) returns a signal that aborts by itself after ms milliseconds, with a DOMException named "TimeoutError" as its reason.
Write debounceAsync(fn, wait) for an async fn, such as a search request. Like debounce, it returns a function that, wait ms after the last call, runs fn once with the this and arguments of that last call. Unlike debounce, every call returns a promise.
Only the latest call's promise gets a result, and it settles the way fn does: it resolves with the value fn's promise resolves to, or rejects with its error, including when fn throws instead of returning a promise.
As soon as a newer call is made, the promise of every earlier call that hasn't settled rejects with an Error whose name is "AbortError" (a DOMException named AbortError works too). That includes a call whose fn is already running: when its result arrives later, it's ignored.
const search = debounceAsync((query) => api.search(query), 300);
search("j").catch((error) => console.log(error.name)); // "AbortError", as soon as the next call comes
search("js").then(showResults); // only ever shows the results for "js"
const clock = useFakeTimers();
const fn = spy(async (value) => value);
const debounced = debounceAsync(fn, 100);
debounced(1).catch(() => {}); // rejects with an AbortError once debounced(2) is called
await clock.tickAsync(50);
debounced(2).catch(() => {});
await clock.tickAsync(99);
expect(fn).toHaveBeenCalledTimes(0);
await clock.tickAsync(1);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenLastCalledWith(2);
const clock = useFakeTimers();
const debounced = debounceAsync(async (query) => query.toUpperCase(), 100);
let result;
debounced("js").then((value) => {
result = value;
}, () => {});
await clock.tickAsync(100);
expect(result).toBe("JS");
useFakeTimers();
const debounced = debounceAsync(async (value) => value, 100);
let error;
debounced(1).catch((reason) => {
error = reason;
});
debounced(2).catch(() => {});
await flush();
expect(error).toHaveProperty("name", "AbortError");
const clock = useFakeTimers();
const debounced = debounceAsync((value) => new Promise((resolve) => setTimeout(() => resolve(value), 500)), 100);
let error;
debounced(1).catch((reason) => {
error = reason;
});
await clock.tickAsync(200); // fn(1) is running
expect(error).toBeUndefined();
debounced(2).catch(() => {});
await flush();
expect(error).toHaveProperty("name", "AbortError");
const clock = useFakeTimers();
const fn = spy((query) => new Promise((resolve) => setTimeout(() => resolve(query.toUpperCase()), 300)));
const search = debounceAsync(fn, 100);
search("a").catch(() => {}); // rejects with an AbortError once search("b") is called
await clock.tickAsync(150); // fn("a") started at 100 and answers at 400
let result;
search("b").then((value) => {
result = value;
}, () => {}); // fn("b") starts at 250 and answers at 550
await clock.tickAsync(300);
expect(fn).toHaveBeenCalledTimes(2);
expect(result).toBeUndefined();
await clock.tickAsync(200);
expect(result).toBe("B");
const clock = useFakeTimers();
const debounced = debounceAsync(async () => {
throw new TypeError("offline");
}, 100);
let error;
debounced().catch((reason) => {
error = reason;
});
await clock.tickAsync(100);
expect(error).toBeInstanceOf(TypeError);
const clock = useFakeTimers();
const debounced = debounceAsync(() => {
throw new RangeError("bad query");
}, 100);
let error;
debounced().catch((reason) => {
error = reason;
});
await clock.tickAsync(100);
expect(error).toBeInstanceOf(RangeError);
const clock = useFakeTimers();
const fn = spy(async () => "ok");
const box = { search: debounceAsync(fn, 100) };
box.search().catch(() => {});
await clock.tickAsync(100);
expect(fn.calls[0].this).toBe(box);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Debounce async calls, latest wins. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.