Skip to content
Buy me a coffee

Express & Fastify

When a client closes the connection (navigates away, times out on its side, presses Escape), the work for that request is wasted. One controller per request stops it: aborted on disconnect, with a server-side deadline as well.

The reliable signal in Node’s HTTP server is the response close event. It fires when the response ends or the connection drops; res.writableFinished tells which:

res.on('close', () => {
if (!res.writableFinished) controller.abort(new Error('client disconnected'));
});

A middleware that gives every request a signal

Section titled “A middleware that gives every request a signal”
request-signal.ts
import type { Request, Response, NextFunction } from 'express';
import { EnhancedAbortController, TimeSpan, isAbortError, isTimeoutError } from '@ylcnfrht/enhanced-abort-controller';
declare global {
namespace Express {
interface Request { signal: AbortSignal }
}
}
export function requestSignal(timeout = TimeSpan.fromSeconds(30)) {
return (req: Request, res: Response, next: NextFunction) => {
const controller = new EnhancedAbortController().abortAfter(timeout);
req.signal = controller.signal;
res.on('close', () => {
if (!res.writableFinished) controller.abort(new Error('client disconnected'));
controller.dispose(); // response done either way: release the timer
});
next();
};
}
app.use(requestSignal());
app.get('/report', async (req, res, next) => {
try {
const rows = await db.query('select ...', { signal: req.signal });
const enriched = await fetch(`${API}/enrich`, { method: 'POST', body: JSON.stringify(rows), signal: req.signal });
res.json(await enriched.json());
} catch (error) {
if (!isAbortError(error)) return next(error);
if (res.headersSent || res.destroyed) return; // the client is gone: nobody to answer
res.status(isTimeoutError(error) ? 504 : 499).end();
}
});

Notes:

  • The disconnect reason is an Error('client disconnected'), not an abort error. fetch and most drivers reject with it as is, so isAbortError is false for it. The handler above checks res.destroyed for that case; alternatively abort with new AbortError('client disconnected'), which isAbortError recognises.
  • 499 is nginx’s “client closed request”; nobody reads it, but it keeps logs honest.
  • Pass req.signal to everything: database drivers (pg, mysql2, MongoDB, Prisma’s interactive transactions where supported), HTTP clients, fs, child processes. A driver that ignores signals keeps running; the response is still skipped.

Give a slow dependency its own budget without losing the request’s cancellation:

app.get('/dashboard', async (req, res) => {
using recommendations = EnhancedAbortController.createLinkedController(req.signal).abortAfter(800);
const [user, recs] = await Promise.all([
loadUser(req.params.id, { signal: req.signal }),
loadRecommendations({ signal: recommendations.signal }).catch(() => []), // optional: give up after 800 ms
]);
res.json({ user, recs });
});

More in Per-step deadlines.

Link each request to an app-wide shutdown controller so in-flight work stops on SIGTERM:

const shutdown = new EnhancedAbortController();
process.once('SIGTERM', () => {
shutdown.abort(new Error('server shutting down'));
server.close();
});
// in the middleware:
const controller = EnhancedAbortController.createLinkedController(shutdown.signal).abortAfter(timeout);

Disposing the per-request controller on close removes its listener from shutdown.signal, so the shutdown signal doesn’t collect one listener per request ever served.