← All writing
Craft · · 6 min

AbortController

On the type-ahead race that isn't a performance problem, the error you have to catch or you'll show an error on every keystroke, and removing 20 event listeners with one call.

JavaScript

A search field on a client site had a peculiar bug. You’d type a product name, results would appear correctly, and then a moment later they’d be replaced by results for a shorter version of what you’d typed.

Which is the classic race, and I’d like to be clear that it’s a correctness bug and not a performance one, because it gets filed under performance and then deprioritized.

Every keystroke fires a request. The requests come back in whatever order the network feels like. So if the response for “chai” arrives after the response for “chair,” the older answer wins, because it arrived last and your code has no idea which is which. Debouncing reduces how often it happens. It doesn’t fix it, because the ordering was never guaranteed at any interval.

Canceling the fetch

The tidy fix is to cancel the previous request every time you start a new one.

search.js
let controller;

input.addEventListener('input', async () => {
  controller?.abort();                        // cancel whatever's in flight
  controller = new AbortController();

  try {
    const res = await fetch(url(input.value), { signal: controller.signal });
    render(await res.json());
  } catch (err) {
    if (err.name === 'AbortError') return;    // we canceled it. not an error.
    showError();
  }
});
JavaScript

The controller is the remote control, the signal is the wire, and you hand the wire to whatever you might want to stop.

That AbortError check is the part everybody misses the first time. Aborting causes the fetch promise to reject, so if your catch block shows an error message, you’ll now show one on every keystroke, and the bug you’ve introduced is more visible than the one you fixed. It’s the sort of thing that gets shipped on a Friday.

Aborting rejects the promise, so a naive catch block turns "we canceled it deliberately" into an error message on every keystroke.

The bit that’s actually more interesting

Here’s the thing I didn’t know until last year, and which I now use far more than the fetch case.

addEventListener accepts a signal.

teardown.js
const controller = new AbortController();
const { signal } = controller;

window.addEventListener('resize', onResize, { signal });
document.addEventListener('keydown', onKey, { signal });
list.addEventListener('click', onClick, { signal });
media.addEventListener('change', onChange, { signal });

// later, all four of them, in one line:
controller.abort();
JavaScript

Which quietly solves the single most irritating thing about removeEventListener, namely that it requires the identical function reference you passed in. So you can’t use an inline arrow function if you’ll ever want to remove it, you have to keep a named reference for every handler, and if the handler was bound or wrapped you have to keep the wrapped version too, and everybody has at some point written a removeEventListener that silently does nothing.

With a signal none of that matters. You don’t need references. You don’t need to remember what you attached. One abort clears everything wired to that signal.

The cases where this is a genuine relief:

Teardown. A component or widget being removed. Create a controller when it initializes, abort it when it goes, and you cannot leak a listener.

Temporary listeners during a drag. Mousedown attaches mousemove and mouseup to the document, and mouseup has to remove both. That’s a small amount of bookkeeping that everybody gets slightly wrong, and it becomes one abort.

Anything you attach conditionally. Where the removal path is harder to reason about than the attachment path, which is most of them.

Wiring your own work to a signal

The other thing worth knowing: a signal is an event target, so you can make your own long-running work cancelable with the same convention.

poll.js
function poll(url, { signal } = {}) {
  const id = setInterval(() => check(url), 30000);
  signal?.addEventListener('abort', () => clearInterval(id), { once: true });
}

// and a signal that's already aborted, for the "cancel before it starts" case:
poll(url, { signal: AbortSignal.abort() });
JavaScript

I’d push this a bit harder than most people do: if you’re writing your own async function that could be canceled, take a signal in an options object rather than inventing a .cancel() method. It’s the platform’s cancellation convention now, it composes, and it means one controller can shut down a fetch, four listeners and your own polling loop together.

What it doesn’t do

Two limits worth being clear about, because “abort” sounds more decisive than it is.

It doesn’t stop the server. Aborting a fetch stops your browser waiting and closes the connection. The request may already have arrived, been processed and committed. So aborting a GET is free, and aborting a POST that charges a card or sends an email tells you nothing about whether it happened.

Which means cancellation is safe for reads and needs thought for writes. If you’re aborting something non-idempotent, you need a way to find out afterward what actually occurred, and that’s a server-side design problem rather than a front-end one.

It’s a request to stop, not a guarantee. Anything consuming a signal has to choose to honor it. The built-ins do. Your own code does if you write the listener. Something in a library might not.

The one nobody announced

The search bug was four lines. The thing I’d actually take away is the listener one, because that’s the one that changed how I write teardown code across everything, and I’d used the fetch version for years without knowing the other existed.

Which is a fairly ordinary experience with the platform, I find. You learn an API for the reason it was introduced, and the genuinely useful part turns out to be a later addition nobody announced, sitting in a paragraph halfway down a specification.

Read similar posts
6 min

View transitions on a multi-page site

There's one argument for building a client-side app I've never had a good answer to: a real page navigation looks worse. The answer turns out to be a browser feature rather than a rebuttal.

6 min

Intl

I added a date library out of pure habit, for one line of formatting, then removed it. Then I read what's actually built in and found four things I've been doing badly for years.