Node handles many HTTP requests through an event loop that runs JavaScript callbacks. A synchronous CPU task inside one route delays callbacks for other clients. Asynchronous I/O avoids waiting on the loop, but an async function that performs a large synchronous parse or sort still blocks before it yields. A request also has several budgets: accepted body size, header and receive timeouts, database deadline, response time, and downstream cancellation. A client disconnect can signal that some read work is no longer useful; it cannot undo a database write that already committed. Budgeting must account for both honest slow clients and deliberately expensive inputs.
Node Request Budgets, Abort, and Event Loop Fairness
Working case
A permit export endpoint accepts an unbounded filter and then sorts 470,000 records in one JavaScript callback. While it runs, a second reviewer cannot load a small case detail. Another client disconnects during a slow report query, but the server continues fetching and allocating results for a response nobody will read. The repair validates and bounds the query, uses a database order and paged cursor rather than full in-memory sort, and passes an abort signal to cancelable read work when the connection closes. Approval writes remain governed by operation identity because cancellation after commit would not revert them. Request and downstream deadlines are measured separately.
Implementation boundary
import express from 'express';
import { permitRepository } from './services.js';
const app = express();
app.get('/api/permits', async (request, response) => {
const rawLimit = Number(request.query.limit ?? 47);
if (!Number.isSafeInteger(rawLimit) || rawLimit < 1 || rawLimit > 47) {
return response.status(400).json({ message: 'Invalid page size' });
}
const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), 8_100);
response.on('close', () => {
if (!response.writableEnded) controller.abort();
});
try {
const page = await permitRepository.searchAuthorized({
reviewerId: request.reviewer.id, limit: rawLimit, signal: controller.signal
});
if (!response.destroyed) response.json(page);
} finally {
clearTimeout(deadline);
}
});Set server and proxy request limits deliberately and apply bounded body parsing before expensive work. Parse filter values with a length and character policy; avoid a regex whose runtime explodes on crafted input. Push indexed filtering and ordering to the database and return one bounded page. Use an AbortController for read-only downstream operations when the service supports cancellation. Check response closure before sending, and remove event listeners when the request finishes so they do not accumulate. Do not infer that an aborted client canceled a committed mutation. Offload truly CPU-heavy, bounded jobs to a worker or background queue when measured event-loop delay warrants it, with a separate job deadline and admission limit.
Cost and boundaries
A database index consumes storage and write maintenance but avoids O(n log n) application sorting for every request. A paged read limits response memory to O(page size). Worker offload adds message transfer and scheduling overhead; it is not free for small tasks. Timeout values trade completion under slow networks against resource occupancy. Abort listeners and controllers add small per-request state and must be cleaned up. Measure event-loop delay, p95 route latency, canceled-read count, database time, memory high-water mark, and the number of active long requests. A fast average can conceal one hostile request starving the loop.
Failure trace
Send an oversized JSON body and confirm rejection before the service call. Request a page size of 470,000 and verify the server clamps or rejects it. Start a slow authorized read, disconnect, and confirm the downstream read is canceled where supported. Start an approval, disconnect just after commit, and verify replay with the same operation ID returns the committed result. Run one intentionally synchronous large sort in a test process while measuring latency of a small detail route; remove that sort from the request path. Force a deadline expiration and confirm no late read response is written to a closed socket.
Verification
- Unbounded input is rejected before expensive work.
- Client disconnect cancels only the read work that supports it.
- A large task does not starve unrelated small requests.
Practice drill
Build a bounded permit search route with a maximum 47-row page, a short query string, and an indexed repository method. Add a request budget that aborts a cancelable read on disconnect or deadline. Instrument event-loop delay and concurrent request count. Drive a small detail request while a large export is running, then compare latency before and after moving export work out of the route callback. Add an approval replay test to document why abort is not rollback. Record the memory budget and timeout ownership at the proxy, HTTP server, and repository layers.
Decision note
Bound input and output, keep synchronous work short, and use abort only for work that is safe to cancel.
Common Mistakes
- Calling an async route nonblocking while it performs a large synchronous sort.
- Treating abort as rollback for a committed write.
- Leaving per-request timers active after completion.
Related lessons
Express Request and Process Boundaries; Express Middleware Order and Request Identity; Express 5 Async Errors and Response Contracts; Node HTTP Drain, Readiness, and Graceful Shutdown; Request Deadlines, Retries, and Backoff; Database Capacity and Online Change Operations.
Apply and check
Build Project: Express permit API lifecycle and review Web Development: Express request and process quiz.
