When a front-end SPA calls fetch('/api/long-task') and the user navigates away, the browser fires an AbortSignal. The common assumption is that the server will magically stop processing the request. In reality the Node.js process keeps running until the handler finishes or you explicitly end it. I ran into this last month when a client cancelled a PDF generation endpoint. The request disappeared from the browser console, but the server kept hammering the CPU for another minute, filling logs and costing us extra cloud credits.

What the abort signal actually does

The AbortController lives entirely in the client environment. When you call controller.abort() the browser:

  • closes the underlying TCP socket,
  • rejects the promise returned by fetch,
  • sends a RST packet to the server.

Node receives the RST as a broken pipe error on the socket. If your route handler never reads from the request stream or never checks the response state, the error is swallowed and the handler proceeds as if nothing happened.

Detecting the abort on the server side

Express (and most other frameworks) expose the raw IncomingMessage object as req. It emits a 'close' event when the connection drops. Listening to that event is the simplest way to know the client gave up.

// server.js (Node 18+, Express 4)
app.post('/api/long-task', (req, res) => {
  let aborted = false;
  req.on('close', () => {
    aborted = true;
    console.log('Client aborted the request');
  });

  // simulate heavy work
  doHeavyWork()
    .then(result => {
      if (aborted) return; // skip sending a response
      res.json({result});
    })
    .catch(err => {
      if (aborted) return;
      res.status(500).send('Oops');
    });
});
  • req.on('close') fires as soon as the socket is gone.
  • Setting a flag lets the async work know whether to continue.
  • Skipping the response avoids "Can't set headers after they are sent" errors.

Early termination of expensive work

Just checking the flag after the work finishes is often too late. For CPU-heavy tasks you should poll the flag periodically and break out.

async function doHeavyWork() {
  for (let i = 0; i < 1e9; i++) {
    if (i % 1e6 === 0 && aborted) {
      throw new Error('Aborted');
    }
    // heavy computation here
  }
  return 'done';
}
  • The modulo check is cheap but gives you a chance to bail out.
  • Throwing an error lets the outer catch handle cleanup.

Cleaning up external resources

If your handler opens files, database connections, or spawns child processes, you must close them when the client aborts. The 'close' listener is the right place.

req.on('close', () => {
  aborted = true;
  if (fileHandle) fileHandle.close();
  if (dbTx) dbTx.rollback();
  if (child) child.kill();
});
  • Always guard each cleanup call; the resource may never have been created.
  • For streams, call stream.destroy() to free buffers.

Using AbortController on the server

Node now ships with a built-in AbortController. You can create one per request and pass its signal to any API that supports it (e.g., fetch, axios, or database drivers). When the client aborts, you propagate the cancellation.

app.get('/api/slow-fetch', async (req, res) => {
  const controller = new AbortController();
  req.on('close', () => controller.abort());

  try {
    const response = await fetch('https://third.party/api', {signal: controller.signal});
    const data = await response.json();
    res.json(data);
  } catch (e) {
    if (e.name === 'AbortError') {
      console.log('Upstream request cancelled because client aborted');
    } else {
      console.error(e);
    }
  }
});
  • The fetch call respects the signal and stops the HTTP request to the third-party service.
  • This pattern prevents unnecessary bandwidth usage.

Common pitfalls

  • Forgetting to return after abort, If you just set a flag but still call res.end(), Node will throw "Cannot set headers after they are sent".
  • Relying on req.aborted property, Express adds a boolean, but it only updates after the 'close' event fires, so you still need the listener.
  • Not handling promises, An unhandled rejection inside the abort flow will crash the process if you don't have a global handler.

A lightweight helper

I keep a tiny utility in my projects to wrap any async handler with abort awareness.

// abortHelper.js
export function withAbort(handler) {
  return async (req, res, next) => {
    let aborted = false;
    req.on('close', () => (aborted = true));
    try {
      await handler(req, res, () => aborted);
    } catch (e) {
      if (aborted) return; // silence errors caused by abort
      next(e);
    }
  };
}
  • The third argument is a function the handler can call to check abort status.
  • It centralises the pattern, so you don't repeat the listener everywhere.

FAQ

How do I know if a request was aborted after the response was sent?

Once res.end() runs, the connection is considered closed from the server side. If the client aborts later, you won't get another event. The only reliable moment is before you send the response.

Does req.on('aborted') exist?

Express adds an aborted boolean but not an event. Use 'close' for the actual notification.

Can I abort a long-running database transaction?

If your driver supports an abort signal (e.g., pg with AbortController), pass the same controller you created for the request. Otherwise you must issue a manual ROLLBACK in the 'close' listener.

Wrapping up

Abort signals are a client-side concept; Node.js will keep doing work unless you tell it otherwise. Listening to the request's 'close' event, propagating a server-side AbortController, and periodically checking a flag are the three tricks that have saved me from wasted CPU cycles and surprise cloud bills. Next time a user navigates away from a heavy endpoint, you’ll know exactly how to stop the server in its tracks.