This page is for anyone preparing for a Node.js round, from a first backend job to a senior role. Most interviews open with the event loop and how one thread handles many requests, then move to streams, buffers and modules, Express middleware and error handling, and API design. Mid and senior rounds add scaling with cluster and worker threads, graceful shutdown, authentication, security and hunting memory leaks, plus a production story and a judgement call. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
One JS thread: your JavaScript runs on a single main thread with one call stack and one event loop.
Non-blocking I/O: network work is handed to the operating system, and Node is told when data is ready.
Not only one thread: libuv keeps a small thread pool for work the OS cannot do asynchronously, like file access.
"It means my JavaScript runs on one main thread, so only one piece of my code executes at any moment. Node gets away with that because most server work is waiting: waiting for a database, a socket, a file. Instead of parking a thread per request, Node starts the I/O, registers a callback, and moves on to the next thing. The operating system tells libuv when a socket has data, and the event loop runs the callback. So thousands of connections can be open while the thread only does short bursts of work for each. The process itself isn't one thread, though. libuv has a small thread pool for things like file system calls and some crypto, and V8 has its own helper threads. The catch is that if my code does heavy CPU work, everyone waits."
Saying Node has exactly one thread in total, or that it's fast because it runs requests in parallel on many threads.
Timers: callbacks from setTimeout and setInterval whose time has passed.
Pending and poll: some deferred system callbacks, then the poll phase that collects new I/O events and runs their callbacks, waiting there if nothing else is due.
Check and close: setImmediate callbacks, then close handlers such as a socket close event.
Between callbacks: the nextTick queue, then promise microtasks, are drained after each callback.
"Each turn of the loop goes through phases in a fixed order. First timers: any setTimeout or setInterval callbacks whose delay has passed. Then pending callbacks, which are a few system-level callbacks deferred from the last turn. There are internal idle and prepare phases I never touch. Then poll, which is the heart of it: libuv asks the OS for finished I/O and runs those callbacks, like data arriving on a socket or a file read finishing. If there's nothing else to do, the loop can wait in poll for new events. After poll comes check, where setImmediate callbacks run, and then close callbacks, like a socket's close event. On top of that, after every single callback Node drains process.nextTick callbacks and then promise microtasks. That's why a promise callback always runs before the next timer or immediate."
Describing one big queue with no phases, or putting promise callbacks in the timers phase.
nextTick: runs right after the current operation, before promise microtasks and before the loop moves on.
setImmediate: runs in the check phase, just after poll.
setTimeout 0: runs in a later timers phase; against setImmediate from the main script the order is not guaranteed.
"process.nextTick isn't really part of the loop phases. Its queue is drained as soon as the current operation finishes, even before promise callbacks. setImmediate runs in the check phase, straight after poll. setTimeout with zero delay goes into the timers phase of a later turn. So in the code I'd show, inside a file read callback, you get sync first, then nextTick, then the promise, then immediate, then timeout. The immediate wins there because we're already in poll, and check comes next. The trap is running the same two from the main script: then timeout versus immediate depends on how quickly the process got to the loop, so the order can change between runs. And I'm careful with nextTick, because if it keeps scheduling itself it starves I/O completely."
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
});
// sync, nextTick, promise, immediate, timeout
Claiming setTimeout 0 always runs before setImmediate, or thinking nextTick waits for the next loop iteration as its name suggests.
Why it exists: some work has no good non-blocking OS API, so libuv runs it on worker threads.
What uses it: file system calls, dns.lookup, async crypto like pbkdf2 and scrypt, and async zlib.
What does not: network sockets use the OS readiness APIs directly, not the pool.
Saturation: the pool is small by default (four threads); extra jobs queue, so unrelated file or DNS work stalls.
"The event loop handles sockets through the operating system's own non-blocking APIs, but some things don't have a good async version everywhere, like file system access. libuv runs those on a small pool of worker threads, four by default. The file system module uses it, so do dns.lookup, the async crypto functions like pbkdf2 and scrypt, and async zlib compression. The surprise is what happens under load. If every login hashes a password with scrypt, those four threads fill up, and now a plain file read or a DNS lookup for an outgoing HTTP call queues behind them. The event loop looks idle, CPU on the main thread is low, but latency climbs. You can raise the size with the UV_THREADPOOL_SIZE environment variable at startup, but I'd first check whether the work belongs in worker threads or another service."
Saying all asynchronous I/O, including network requests, runs on the thread pool.
Why it hurts: while one callback runs, no other callback can, so every request waits behind it.
Usual culprits: sync fs or crypto calls, parsing or stringifying huge JSON, heavy loops, regexes that backtrack badly.
Fixes: use the async API, split the work into chunks, move CPU work to worker threads or another service.
Measure: watch event loop delay, for example with perf_hooks.monitorEventLoopDelay.
"Anything that keeps the main thread busy for a long stretch blocks the loop. The common ones I've seen are sync calls like readFileSync or a sync password hash inside a request handler, JSON.parse or JSON.stringify on a huge payload, a nested loop over a big array, and regexes that backtrack badly on certain input. It hurts everyone because while that callback runs, nothing else can. New connections wait, other responses aren't sent, even timers are late. So one user uploading a giant file to a sync parser raises latency for all users at once. To fix it I use the async version of the API, paginate or stream so each chunk is small, or move genuinely CPU-heavy work to worker threads or a separate service. And I track event loop delay as a metric, so I see it before users do."
Saying async functions can't block the loop, when an async function full of synchronous work blocks it just the same.
The pattern: the last argument is a callback called as (err, result); check err first.
promisify: util.promisify wraps a function that follows that pattern and returns a promise.
Built-ins: many core modules already ship a promise version, like node:fs/promises.
"Older Node APIs take a callback as the last argument, and call it with the error first and the result second. The rule is you always check err before touching the result, and you call the callback exactly once. It works, but chaining several steps gets nested and error handling repeats everywhere. To use one of those with async and await, I wrap it with util.promisify, which gives me a function that returns a promise, resolving with the result or rejecting with the error. For core modules I usually don't need to, because there's node:fs/promises and promise versions of timers and streams. If a callback API doesn't follow the error-first shape, like one that passes two results, I write a small new Promise wrapper by hand instead."
const { promisify } = require('node:util');
const dns = require('node:dns');
const lookup = promisify(dns.lookup);
async function main() {
const { address } = await lookup('example.com');
console.log(address);
}
main().catch(console.error);
Wrapping every call in new Promise by hand without knowing util.promisify or the built-in promise APIs exist.
Idea: data flows in chunks, so you never hold the whole thing in memory.
Readable and Writable: a source you read from, a destination you write to.
Duplex and Transform: both at once; a Transform changes data passing through, like gzip.
Why: flat memory use, and the first bytes go out before the last bytes arrive.
"A stream lets you handle data a chunk at a time instead of loading all of it. There are four kinds. Readable is a source, like fs.createReadStream or an incoming HTTP request. Writable is a destination, like a file write stream or the HTTP response. Duplex is both, independently, like a TCP socket. Transform is a duplex where the output is computed from the input, like a gzip stream or a CSV parser. If I read a two gigabyte log with readFile, the whole thing sits in memory, and with a few users at once the process can run out. Streaming it keeps memory roughly flat no matter the size, and the client starts receiving bytes almost immediately. For small config files, readFile is simpler and perfectly fine."
Describing streams as just a faster way to read files, without mentioning memory or chunking.
The problem: the producer is faster than the consumer, for example a fast disk and a slow client.
The signal: write() returns false once the internal buffer passes highWaterMark.
The response: stop writing and wait for the 'drain' event, then continue.
Easy route: pipe or pipeline handle this for you.
"Backpressure is what happens when data arrives faster than the next stage can take it, like reading from a fast disk and sending to a slow mobile client. A writable stream has an internal buffer with a limit called highWaterMark. When a write pushes the buffer past that limit, write() returns false. That's not an error, it's a request to stop. The right move is to pause and wait for the 'drain' event before writing more. If I ignore it and keep writing in a loop, nothing fails straight away. Node keeps accepting the data and buffering it in memory, so memory climbs until the process slows down on garbage collection or gets killed. In practice I rarely handle it by hand, because pipeline and pipe pause the source and resume it on drain automatically."
const { once } = require('node:events');
async function writeAll(stream, rows) {
for (const row of rows) {
if (!stream.write(row + '\n')) {
await once(stream, 'drain'); // wait until the buffer empties
}
}
stream.end();
}
Believing a false return from write() means the data was dropped, or never having heard of the drain event.
Streams: read stream, gzip transform, write stream.
pipeline: connects them, handles backpressure, and destroys every stream if any one fails.
Errors: await it inside try and catch, and clean up the partial output file.
"I'd connect three streams: a read stream on the log, zlib's createGzip transform, and a write stream for the output. I use pipeline from node:stream/promises rather than chaining pipe calls. The reason is error handling. With plain pipe, an error on one stream isn't passed along, and the other streams aren't closed, so you can leak file handles or leave a half-written file with the process still waiting. pipeline wires up backpressure the same way pipe does, but if any stream errors it destroys all of them and rejects the promise, so one try and catch covers the whole chain. In the catch I delete the partial output, because a truncated gzip file that looks valid is worse than no file. Memory stays flat whatever the input size."
const fs = require('node:fs');
const zlib = require('node:zlib');
const { pipeline } = require('node:stream/promises');
async function gzipFile(src, dest) {
try {
await pipeline(
fs.createReadStream(src),
zlib.createGzip(),
fs.createWriteStream(dest)
);
} catch (err) {
await fs.promises.rm(dest, { force: true }); // no half-written file
throw err;
}
}
gzipFile('app.log', 'app.log.gz').catch((err) => {
console.error('compress failed:', err.message);
process.exitCode = 1;
});
Using readFile and zlib.gzipSync on the whole file, or chaining pipe calls with no error handling.
Buffer: a fixed-length chunk of raw bytes, a subclass of Uint8Array.
String: text; converting needs an encoding like utf8, base64 or hex.
Length trap: a string length counts UTF-16 code units, Buffer.byteLength counts bytes.
Chunk trap: a multi-byte character can be split across two chunks.
"A Buffer is raw binary data with a fixed size. It's actually a Uint8Array with extra methods. Strings are text, and going between the two always involves an encoding, like utf8, base64 or hex. Two things bite people. First, length. The string 'é' has length one, but in UTF-8 it's two bytes, so if I set a Content-Length header from the string length, I get it wrong for non-English text. I use Buffer.byteLength for that. Second, chunks. If I read a stream and do string += chunk, each chunk is decoded on its own, and a character whose bytes fall across two chunks turns into garbage. The fix is to collect the buffers and call Buffer.concat once, or call setEncoding('utf8') on the stream so a decoder handles the split. And I use Buffer.alloc, not allocUnsafe, unless I'm filling every byte myself."
Treating string length and byte length as the same thing, or concatenating binary chunks as strings.
CommonJS: require and module.exports; loaded synchronously; __dirname and __filename available.
ES modules: import and export; static structure; top-level await; import.meta.url instead of __dirname.
How Node decides: .mjs is ESM, .cjs is CommonJS, .js follows the "type" field of the nearest package.json.
Interop: ESM can import CommonJS; CommonJS reaches ESM through dynamic import().
"CommonJS is Node's original system: require loads a file synchronously and returns whatever it put on module.exports. ES modules are the standard JavaScript system, with import and export. The imports are static, so tools can analyse them, loading is asynchronous, and you get top-level await. ESM is strict by default, relative imports need the file extension, and there's no __dirname, you derive it from import.meta.url. Node decides per file: .mjs is always ESM, .cjs is always CommonJS, and a .js file follows the type field in the nearest package.json, where 'module' means ESM and 'commonjs' means CommonJS. With no field it's treated as CommonJS, though newer versions switch to ESM if they spot import syntax. Going from ESM to CommonJS is easy, you just import it. The other direction was historically blocked, you had to use dynamic import(), though newer Node versions relax that for modules without top-level await."
Saying the difference is only syntax, with no idea how Node picks the module type for a file.
Cache: the first require runs the file; later ones return the cached module.exports.
Key: the cache is keyed by the resolved file path, not the string you passed.
Consequences: module-level state is shared; two installed copies of a package mean two instances.
"No, they share one. The first time a module is required, Node resolves it to a full file path, wraps the code in a function that provides exports, require, module, __filename and __dirname, runs it once, and stores the result in a cache keyed by that path. Every later require of the same path gets the same module.exports object back. That's why a database pool created at the top of a db module works as a singleton across the app. It has two catches. If the same package is installed twice in node_modules at different versions, those are two paths, so two instances, which is how you end up with two copies of a library that each think they're the only one. And with circular requires, one side gets a partly filled exports object, which shows up as something being undefined."
Believing each require re-runs the file, or that module-level variables are private to each caller.
Ranges: package.json holds ranges like ^1.4.0, which allow newer minor and patch versions.
Lock file: records the exact version and integrity hash of every package in the tree.
npm ci: installs exactly the lock, from a clean node_modules, and fails if the lock and package.json disagree.
"package.json usually lists ranges, not exact versions. A caret range like ^1.4.0 accepts any 1.x from 1.4.0 up, and a tilde only accepts patch updates. Without a lock file, two installs a week apart can pull different versions of some deep dependency, and the build breaks with no code change. package-lock.json fixes that by recording the exact version and integrity hash of every package in the tree, including transitive ones, so it gets committed. On my machine I use npm install when I'm adding or updating packages, because it can update the lock. In CI and in Docker builds I use npm ci. It deletes node_modules, installs exactly what the lock says, never rewrites it, and fails loudly if package.json and the lock have drifted apart."
Adding package-lock.json to .gitignore, or thinking the caret pins an exact version.
Why: the reason, like end of support, a security fix or a feature you needed.
Prepare: read the breaking changes, run tests on both versions, fix deprecation warnings first.
Roll out: canary or one service at a time, with a quick way back.
Learn: what surprised you and what you would repeat.
"Our API was on a Node version that was about to lose security support, so I led the move two major versions up. I read the release notes for each version in between and made a list of what touched us. Then I added the new version to our CI matrix, so every pull request ran on both for a couple of weeks. That flushed out two things: a native module that had to be rebuilt and bumped, and a test that depended on the old default behaviour of unhandled rejections. I ran the app with deprecation warnings turned on and fixed those first. Then we shipped to one instance as a canary, watched errors, memory and latency for a day, and rolled out the rest. The main thing I'd repeat is running both versions in CI early, since it turned surprises into a to-do list."
Bumping the version straight in production and fixing whatever broke afterwards.
Assess: is the vulnerable code actually reachable with our inputs, and in production or only dev tooling?
Trace: find who pulls it in, for example with npm ls, and check for a newer parent version.
Patch: use the overrides field in package.json to force a fixed version, then run the tests.
Record: if nothing works yet, add a mitigation, document the decision and set a date to revisit.
"First I'd read the advisory properly and work out whether we're actually exposed. Is the package in production code or only in a build tool, and does our app ever pass untrusted input to the vulnerable function? Then I'd run npm ls on the package to see which dependency pulls it in, and check whether a newer version of that parent already fixes it. If not, and a patched version of the deep package exists, I'd use the overrides field in package.json to force it, and run the full test suite, because the parent never tested against that version. I wouldn't run npm audit fix with force blindly, since it can jump major versions. If there's no patch at all and we're exposed, I'd add a mitigation like stricter input validation, record the decision, and put a date on revisiting it."
Ignoring the alert because it's not a direct dependency, or running a forced audit fix straight onto main.
Shape: a function (req, res, next) that runs in the order it was registered.
Each one must: send a response, or call next() to pass control on.
next(err): skips the normal middleware and jumps to error handlers.
Order matters: body parsing and auth before routes, error handlers last.
"Middleware in Express is a function that gets the request, the response, and a next function. Express keeps them in a list in the order I register them with app.use or on a route, and runs them one by one. Each one can read or change req and res, and then it has to do one of two things: send a response, or call next() to hand over to the next function. If it does neither, the request just hangs until the client times out. Calling next with an argument, like next(err), tells Express to skip the remaining normal middleware and go straight to the error handlers. Because it's a pipeline, order is everything. Body parsing has to come before a route that reads req.body, auth before protected routes, and the error handler after all the routes."
Not knowing that a middleware which neither responds nor calls next() leaves the request hanging.
Error middleware: a function with four arguments (err, req, res, next), registered after the routes.
Async gap: Express 4 catches sync throws but not rejected promises; Express 5 forwards them.
Fix in 4: wrap handlers so rejections go to next(err).
Response: one consistent error shape, correct status, no stack traces to clients.
"Express recognises an error handler by its four arguments: err, req, res, next. I register one after all the routes, and anything that calls next(err) or throws synchronously in a handler lands there. The trap is async code in Express 4. If an async handler throws, it returns a rejected promise, and Express 4 doesn't look at the return value, so the request hangs, and on current Node the unhandled rejection can crash the whole process. So in Express 4 I wrap handlers in a small helper that catches the rejection and passes it to next. Express 5 does that for you, it forwards rejected promises to the error handler. In the handler itself I map known errors to proper status codes, log the full error with a request ID, and send clients a short message without a stack trace."
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await orders.find(req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
res.json(order);
}));
// Registered last: four arguments marks it as an error handler
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
req.log?.error(err);
res.status(err.status || 500).json({ error: err.expose ? err.message : 'Internal error' });
});
Putting try and catch in every route with res.status(500) copied around, or not knowing Express 4 misses rejected promises.
Routes: plural nouns and HTTP verbs, like GET /orders, POST /orders, GET and PATCH /orders/:id.
Status codes: 201 on create, 204 on delete, 400 or 422 for bad input, 401 versus 403, 404, 409 on conflicts.
Validation: a schema check on body, query and params before any business logic.
Pagination: a limit with a maximum, and a cursor rather than a large offset.
"I'd use nouns and let the verbs do the work. GET /orders lists, POST /orders creates, GET /orders/:id reads one, PATCH updates part of it, and DELETE or a cancel action removes it. Status codes should mean something: 201 with the new resource on create, 204 when there's no body, 400 or 422 for input that fails validation, 401 when you're not logged in, 403 when you're logged in but not allowed, 404 for a missing order, 409 for something like cancelling an order that's already shipped. I validate body, query and params against a schema in middleware, so handlers only see clean data. For lists I take a limit with a hard cap and return a cursor for the next page, because large offsets get slow and skip rows when data changes. Errors always come back in one JSON shape."
Returning 200 for everything with an error flag in the body, or trusting request data without validation.
Environment variables: read from process.env, one build that runs everywhere.
Load and validate once: a config module that parses, sets types and fails fast at startup.
Secrets: never in git; a .env file only for local work; a secret manager in production.
NODE_ENV: set to production in production, since libraries change behaviour on it.
"I keep config out of the code and read it from environment variables, so the same build runs in every environment. Locally I use a .env file, which is in .gitignore, and recent Node versions can load one with a command-line flag without an extra package. I don't sprinkle process.env across the codebase. There's one config module that reads everything at startup, turns strings into numbers or booleans, checks required values are present, and throws if anything is missing. A crash on boot is much better than a crash at two in the morning when that code path finally runs. Secrets like database passwords come from the platform's secret manager in production, never from the repo. I also make sure NODE_ENV is production there, because Express and other libraries switch to faster, less verbose behaviour based on it."
Committing a .env file with real credentials, or reading process.env deep inside business logic with no checks.
Stop taking traffic: fail the readiness check and wait a few seconds so the load balancer stops sending new requests.
server.close(): stops new connections and waits for in-flight ones; idle keep-alive sockets need closing too.
Clean up: close database pools and queues after the server has drained.
Deadline: a timer forces exit if draining takes too long.
"When SIGTERM arrives I don't exit straight away. First I flip a flag so the health check starts failing, which tells the load balancer to stop sending me new traffic, and I give it a few seconds to notice. Then I call server.close(), which stops accepting new connections and waits for the ones in flight to finish. One catch is keep-alive: idle connections can hold the server open, so I close idle ones explicitly. Once close's callback fires, I close the database pool and any queue consumers, flush logs, and exit with code zero. I also set a hard deadline with an unref'd timer, so if some request is stuck, the process still exits before the orchestrator kills it anyway. Without all this, a deploy cuts off requests mid-flight and users see random errors every time we ship."
const server = app.listen(3000);
let shuttingDown = false;
app.get('/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));
process.on('SIGTERM', () => {
shuttingDown = true;
setTimeout(() => process.exit(1), 20_000).unref(); // hard deadline
setTimeout(() => { // let the load balancer see the 503 first
server.close(async () => { // runs when in-flight requests are done
await db.end();
process.exit(0);
});
server.closeIdleConnections(); // idle keep-alive sockets
}, 5_000);
});
Calling process.exit() as soon as the signal arrives, or not knowing server.close() waits for open connections.
cluster: several Node processes sharing one server port, to use all CPU cores for request handling.
Worker threads: extra threads in one process for CPU-heavy JavaScript; message passing, optional shared memory.
Child process: run another program or script, like a shell command or an image tool.
Today: many teams run one process per container and let the platform scale out.
"They solve different problems. cluster forks several copies of my server, each a full Node process with its own memory and event loop, and they share one listening port, so I can use every core for handling requests. Because they share nothing, in-memory state like sessions has to move to a shared store. Worker threads are for CPU-heavy JavaScript, like image resizing or parsing a big report, inside the same process. Each worker has its own event loop, and I talk to it with messages, or share memory with a SharedArrayBuffer if I really need to. They don't help with I/O, which is already non-blocking. Child processes are for running other programs, like ffmpeg or a Python script, through spawn or execFile. In containers I often skip cluster entirely and run one process per container, letting the orchestrator scale."
Suggesting worker threads to speed up database or HTTP calls, which are already asynchronous.
Split roles: isMainThread tells the same file whether it is the parent or the worker.
Pass data: workerData in, parentPort.postMessage out.
Wrap in a promise: resolve on message, reject on error or a non-zero exit.
Production: reuse a pool of workers instead of starting one per request.
"I'd keep it in one file and use isMainThread to decide the role. In the main thread, I wrap the worker in a promise: create a Worker pointing at this file, pass the input as workerData, resolve on the first message, and reject on an error event or a non-zero exit code, so a crashed worker never leaves the caller waiting forever. In the worker branch, it reads workerData, does the heavy calculation and posts the result back through parentPort. The main thread stays free the whole time, which the interval in the example shows by still printing. For a real service I wouldn't start a new worker per request, because each one has its own V8 instance and startup cost. I'd keep a fixed pool sized to the cores and queue jobs to it, with a timeout so a runaway job can be terminated."
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
if (isMainThread) {
const runFib = (n) => new Promise((resolve, reject) => {
const worker = new Worker(__filename, { workerData: n });
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`worker exited with code ${code}`));
});
});
setInterval(() => console.log('main thread still free'), 200).unref();
runFib(40).then((result) => console.log('fib(40) =', result));
} else {
parentPort.postMessage(fib(workerData));
}
Starting a new worker for every request with no pool, or ignoring the error and exit events.
Sessions: move to a shared store, or to tokens; sticky sessions are only a stopgap.
Counters and limits: rate limits need a shared store, or each instance allows the full limit.
Caches and jobs: in-memory caches drift apart; scheduled jobs would run once per instance.
Real-time: websocket fan-out needs a shared channel between instances.
"The rule is that any state living in process memory stops being true once there's more than one process. Sessions first: a user logs in on instance A, the next request lands on B, and they're logged out. I'd move sessions to a shared store like Redis. Sticky sessions would hide it, but they break on every deploy and spread load unevenly. Rate limits have the same issue: with three instances each counting on its own, a client gets three times the limit, so counters go into the shared store too. Then I'd hunt for the less obvious ones. In-memory caches will disagree between instances, so I'd accept short expiry times or invalidate through a shared channel. Any setInterval job would now run on every instance, so it needs a lock or a separate worker. And websocket messages need a shared pub-sub to reach users on other instances."
Turning on sticky sessions and calling it done, without noticing rate limits, caches or scheduled jobs.
Default: an uncaught exception crashes the process; since Node 15 an unhandled rejection does too.
Why crash: the error escaped mid-operation, so state like open transactions or half-updated objects is unknown.
Handler job: log with context, try a quick cleanup, exit with a non-zero code.
Recovery: a supervisor or orchestrator restarts it; several instances keep traffic flowing.
"By default an uncaught exception kills the process, and since Node 15 so does an unhandled rejection. That default is right. When an error escapes all the way to the top, it has jumped out of the middle of something, maybe a half-applied update, a lock that was never released, or a connection left in a strange state. Keeping that process alive means serving requests from a state nobody reasoned about, which can be worse than an outage. So my handler for uncaughtException and unhandledRejection logs the error with as much context as it has, does only quick, safe cleanup, and exits with a non-zero code. Something outside the process, like the container orchestrator, restarts it, and because we run several instances, users barely notice. Then I fix the actual bug, since each of these is one."
Registering a handler that logs and carries on as normal so the server never goes down.
Situation: what users saw and how you found out.
Evidence: the metrics, logs, profiles or snapshots you used, and what you ruled out.
Root cause and fix: the actual line or pattern, plus the short-term mitigation.
Prevention: the alert, test or rule that stops it coming back.
"At my last company, our notification service started restarting every few hours with out-of-memory kills. Users saw delayed emails. I checked the memory graph first: the floor rose steadily after every deploy, so it looked like a leak, not a traffic spike. I took two heap snapshots about twenty minutes apart on a staging copy under replayed traffic and compared them. The growth was thousands of socket objects retained by a listener array. We were adding an 'error' listener to a shared client on every request and never removing it. The warning about too many listeners had actually been in our logs for weeks. As a quick fix we rolled back, then moved the listener to setup code. After that I made that warning fail our tests and added a memory-growth alert, so we'd catch it within hours, not days."
A story where the fix was restarting the service more often, with no root cause found.
Sessions: a random ID in a cookie, data in a server-side store; easy to revoke, needs a shared store.
JWT: signed claims the server can verify without a lookup; hard to revoke before expiry.
JWT hygiene: short expiry, refresh tokens, fixed allowed algorithm, signed but not encrypted.
Storage: an httpOnly, Secure, SameSite cookie beats localStorage, which any XSS can read.
"For a normal web app with a browser front end, I lean towards sessions. The server gives the browser a random session ID in an httpOnly, Secure, SameSite cookie and keeps the real data in a store like Redis. Logging someone out or banning them is just deleting that record. The cost is a lookup per request and a shared store once you have more than one instance. JWTs make sense when many services need to verify identity without calling back to one place. The token is signed, so any service with the key can trust it without a lookup. But it's signed, not encrypted, so the payload is readable, and you can't easily revoke it, so I keep access tokens short-lived with a refresh token that can be revoked. I also pin the expected algorithm when verifying, rather than trusting the token's header."
Putting sensitive data in a JWT payload assuming it is hidden, or long-lived JWTs stored in localStorage with no way to revoke them.
Input: validate everything with a schema, cap body size, use parameterised queries.
HTTP layer: security headers, strict CORS allowlist, rate limits on login and costly routes.
Node-specific: watch for regexes that backtrack badly, prototype pollution from merged JSON, and exec with user input.
Supply chain and runtime: audit dependencies, commit the lock file, no stack traces to clients, run as a non-root user.
"I start with input. Every body, query and param goes through a schema, and I cap the JSON body size so nobody can send a huge payload. Database access is parameterised, never string-built. On the HTTP side I set security headers, usually with a small middleware like helmet, lock CORS down to the origins that actually need it, and rate-limit login and anything expensive. Then there are Node-flavoured risks. A regex that backtracks badly can freeze the whole event loop, so I'm careful with user-supplied patterns. Deep-merging untrusted JSON into objects can cause prototype pollution through a __proto__ key. And I never pass user input into exec. Finally, the boring but important bits: npm audit in CI, a committed lock file, errors that don't leak stack traces, secrets from the environment, and the process running as a non-root user."
Answering only with "use HTTPS" and "use a security package", with nothing about input validation or the event loop risks.
Confirm: track heapUsed and rss over time; a sawtooth that keeps rising under steady load is a leak.
Snapshot and compare: take heap snapshots a while apart and look at what grew and what retains it.
Usual suspects: unbounded in-memory caches or maps, listeners never removed, timers never cleared, closures holding large objects.
Fix and prove: bound or remove the reference, then show memory flatten under the same load.
"First I confirm it's a leak. I look at heapUsed and rss from process.memoryUsage over time. If memory drops after each garbage collection but the floor keeps rising under steady traffic, something is being held on to. Then I take heap snapshots, either with the inspector and Chrome DevTools or v8.writeHeapSnapshot, a few minutes apart, and compare them. I look for object types whose count only grows, and follow the retainer path to see what's keeping them alive. Snapshots pause the process and need a lot of extra memory, so I do it on one instance out of rotation or reproduce it locally under load. In my experience it's nearly always a module-level Map used as a cache with no limit, event listeners added per request and never removed, or intervals that are never cleared. Raising the heap limit only delays the crash."
Answering "increase --max-old-space-size" or "restart it every night" as the fix.
Baseline: the latency or throughput number, and how you measured it.
Find the cost: profiling, tracing or event loop delay pointed to one cause.
Change: the specific fix and why it worked in Node terms.
Result: the before and after, and any trade-off.
"In my last role our search endpoint was slow at busy times, and the slowdown spread to unrelated endpoints too, which was the clue. I started with a baseline: I replayed a copy of real traffic against staging with a load tool and recorded latency and event loop delay. Event loop delay jumped whenever search got busy, so something was burning the main thread. I captured a CPU profile with the --cpu-prof flag and opened it as a flame graph. Most of the time was in JSON.stringify on a huge response, because we returned every field of every match. We added pagination and a field list, so responses were a fraction of the size, and cached the one expensive lookup. Under the same replay, latency across the service dropped to about a third of what it was, and the other endpoints stopped suffering."
Optimising by guesswork, like adding caching everywhere, with no baseline or profile to show what was slow.
Users first: if impact is real, roll back, then investigate.
Shared bottleneck: everything slow points at the event loop, the thread pool, garbage collection or a shared dependency.
Check signals: event loop delay, CPU, memory and GC, database latency, compared with before the release.
Find it: diff the release, profile a canary with a CPU profile, look for new synchronous work.
"If users are clearly hurting, I roll back first, since that's usually faster than diagnosing live. The fact that every endpoint slowed down is the big clue. In Node that points to something shared, most often a blocked event loop, but it could be a saturated thread pool, garbage collection pressure, or a slower database. So I compare dashboards before and after: event loop delay, CPU, memory, and database latency. If event loop delay jumped along with CPU, some new code is doing synchronous work on the main thread. Then I diff the release looking for readFileSync, a sync hash, a big JSON.stringify in a log line, or a new regex, and I'd confirm it with a CPU profile from a canary instance. Afterwards I'd add an event loop delay alert, so the next one shows up before users report it."
Adding more instances straight away without checking why every request on each instance got slower.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.