Node.js in Production: Error Isolation and Sustained Performance
Patterns for keeping latency stable under load: process limits, backpressure in streams, and useful observability, without relying on heavy frameworks.
Node.js is comfortable to start and surprisingly easy to leave in a fragile state. A single shared thread for the whole application gets along well with moderate load and very poorly with an uncaught exception, a busy-wait loop, or a queue that grows without bound. This article summarizes the patterns we use so that a Node.js service keeps predictable latency once load stops being the load of a development environment.
1. The single-thread model and its limits
The event loop processes one event at a time. That means any heavy synchronous work blocks everything else: no request progresses while a huge object is serialized or an expensive hash is computed. The practical consequence is a rule: the main thread must not do CPU-intensive work.
For this kind of task there are two reasonable paths:
worker_threads: run the computation on a separate thread and return the result by message.- Delegate to an external process: move the work to a dedicated service, for example in a different language.
The common mistake is not choosing the wrong tool, but mixing CPU and I/O in the same request and discovering the problem only when latency degrades in production.
2. Isolating errors: from global try/catch to process boundaries
Wrapping the whole bootstrap in a try/catch does not protect the process. An exception thrown inside an asynchronous handler does not return to the bootstrap point: it surfaces as an uncaughtException or as an unhandled promise rejection.
process.on('unhandledRejection', (reason) => {
logger.fatal({ err: reason }, 'Unhandled promise rejection');
shutdown('unhandledRejection');
});
process.on('uncaughtException', (error) => {
logger.fatal({ err: error }, 'Uncaught exception');
shutdown('uncaughtException');
});
The default strategy for these events should be to terminate the process in an orderly way, not to try to keep going. A process that survives an inconsistent state is usually worse than one that restarts clean. The orchestrator will bring up a fresh instance.
Beyond that, every request should have its own error boundary. A middleware that captures errors per request prevents one user’s failure from taking down the entire service:
async function withErrorBoundary(req, res, next) {
try {
await next();
} catch (error) {
logger.error({ err: error, path: req.path }, 'Request failed');
res.status(500).json({ error: 'internal_error' });
}
}
3. Backpressure: do not accumulate work in memory
When a producer is faster than a consumer, the difference piles up. Without an explicit mechanism, that pile lives in memory until the process consumes everything available and dies. Node.js streams have built-in backpressure, but you have to use it: concatenating data into a variable is exactly what turns it off.
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { Transform } from 'node:stream';
const transform = new Transform({
transform(chunk, _encoding, callback) {
callback(null, chunk);
},
});
await pipeline(
createReadStream('input.log'),
transform,
createWriteStream('output.log')
);
pipeline propagates errors to every endpoint and releases resources when it finishes. For work queues the principle is the same: set a maximum capacity and decide what to do when it is reached, whether rejecting with a clear status code or applying backpressure to the producer. A queue-depth counter exposed as a metric turns that limit into something observable rather than a surprise.
4. Memory limits and controlled restarts
By default, Node.js sizes the heap from system memory, not from the container’s actual limit. If the container limit is 512 MB and the heap tries to grow beyond it, the process is killed by the kernel with no clear message.
The fix is to align the two:
node --max-old-space-size=384 dist/server.js
The heap stays deliberately below the container limit, leaving room for native memory and buffers. A restart on excessive consumption should be an explicit, logged decision, not a side effect of the operating system killing the process.
5. Minimal and useful observability
The observability that actually gets used during an incident fits in four signals:
- Latency by percentile, not just the average. The 99th percentile reveals what the average hides.
- Error rate broken down by status code, to distinguish a client failure from a server one.
- Queue depth and memory usage, as early indicators of saturation.
- Traces with one identifier per request, so a specific case can be followed through the system.
Logging everything without criteria produces noise; logging these four signals consistently produces diagnoses. Structured logging in JSON, with stable fields such as requestId and durationMs, additionally allows filtering without relying on fragile expressions over free text.
6. Conclusion
Keeping a Node.js service stable does not depend on a particular library, but on accepting its limits: a main thread that must not block, errors that should terminate processes in an orderly way, queues with bounded capacity, and memory sized for the real container. These patterns add no appreciable complexity and remove most of the failure modes that only appear under load.