Skip to the code
cf.

Debounce async calls, latest wins

HardTiming

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"

What the tests check

  1. runs fn once, wait ms after the last call, with the latest arguments
    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);
    
  2. resolves the latest call's promise with fn's result
    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");
    
  3. rejects an earlier call with an AbortError as soon as a newer call is made
    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");
    
  4. rejects a call whose fn is already running when a newer call comes
    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");
    
  5. ignores an old result that arrives after a newer call
    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");
    
  6. rejects the latest call's promise when fn rejects
    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);
    
  7. rejects when fn throws instead of returning a promise
    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);
    
  8. keeps the caller's this
    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);
    

Learn more

solution.jsSaved in this browser

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).