Node.js Backend Developer Top 100 Interview Questions
100 Node.js backend developer interview questions with structured answers, comparison tables, flow diagrams, worked calculations and runnable code. Covers the event loop, async patterns, streams, HTTP servers, databases, caching, testing, security, performance and system design, from the basics to production scenarios.
Questions 61-80 of 100
Q61.How do you stop errors in async route handlers from crashing the process or hanging requests?
Make sure every rejected promise from a handler reaches one central error handler that sends a response. Unhandled, a rejection either crashes the process or leaves the request open until the client times out.
flowchart TD A["Async handler throws or rejects"] --> B{"Framework awaits handler promises?"} B -->|yes| C["Error passed to error middleware"] B -->|no| D["Wrap handler to catch and forward error"] D --> C C --> E["Log with request ID"] E --> F["Send consistent error response"]- Know your framework: some await handler promises and route rejections to the error handler; older ones need every async handler wrapped in a function that catches and forwards errors.
- One error middleware maps known errors to status codes and unknown ones to a generic 500, and logs the details.
- Never respond twice: if headers are already sent, such as during streaming, end or destroy the response instead.
- Fire-and-forget work started from a handler needs its own
.catch, because no one awaits it. - Timeouts: set server request timeouts, so a handler that never responds cannot hold a connection forever.
Q62.When should a Node.js API compress responses, and when should it not?
Compress text responses such as JSON, HTML and CSV above a small size threshold when clients accept it; skip already-compressed content and tiny bodies. Compression saves bandwidth and time for clients, but it costs CPU on the server.
Compress Skip compression JSON, HTML, CSV and other text Images, videos, archives and other compressed formats Bodies above about a kilobyte Tiny bodies, which gain little and may even grow Responses with no secrets next to attacker-controlled text Responses that mix secrets with attacker-controlled text - Where to compress: often at a reverse proxy or content delivery layer, which keeps the CPU work off Node.js processes. Compress in Node.js when there is no such layer.
- Thread pool: the asynchronous
zlibfunctions run on the thread pool, so heavy compression competes with file and crypto work; the synchronous versions block the event loop. - Streaming: compression fits in a stream pipeline, so large responses stay in constant memory.
- Levels: higher compression levels give small extra savings for much more CPU; moderate levels are usually best for dynamic responses.
Security caveat: compressing responses that mix secrets, such as tokens, with attacker-controlled text can leak the secret through response sizes. Avoid compressing such responses, or keep secrets out of compressed bodies.
Q63.How do you build real-time features such as WebSockets in Node.js?
Node.js suits real-time features well, because each open connection costs little while idle. The challenges are authentication, fan-out across processes, slow clients and keeping connections healthy.
flowchart LR A["Client"] -->|upgrade request| B["Node.js process"] B --> C["Authenticate and join rooms"] D["Other process publishes event"] --> E["Shared message broker"] E --> B B --> A- Upgrade handling: a WebSocket starts as an HTTP request that is upgraded; authenticate during that request, using a token or session cookie, and check the origin.
- Fan-out across processes: clients connect to different processes, so publish events through a shared broker channel that every process subscribes to.
- Rooms and permissions: check authorization on every subscription, not just at connect time.
- Slow clients: watch the socket's buffered amount and drop or disconnect clients that fall far behind, so memory cannot grow without limit.
- Heartbeats: send pings to detect dead connections and keep proxies from closing idle ones.
- Load balancing: long-lived connections can stay unevenly spread after scaling; reconnect clients gradually during deploys.
Server-sent events are simpler when updates only flow from server to client, since they use plain HTTP.
Q64.How do REST and GraphQL APIs differ, and what risks does GraphQL bring?
REST exposes resources at separate URLs with fixed response shapes. GraphQL exposes one endpoint and a typed schema, and each client sends a query describing exactly the fields it needs, including nested related data.
REST GraphQL One URL per resource, using HTTP methods One endpoint; queries and mutations select fields The server decides each response's shape The client decides which fields come back Several round trips for related data, or many endpoints Related data in one request Simple HTTP caching by URL Caching needs extra work, often by query or by object Easy rate limiting per endpoint Cost depends on each query's depth and size Risks GraphQL brings, and how to handle them:
- Expensive queries: deeply nested or huge queries can overload the server; enforce depth and complexity limits and paginate lists.
- Many small queries: resolving nested fields per item triggers floods of database queries; use batching loaders.
- Field-level authorization: every resolver must check permissions, since clients can ask for any field in the schema.
- Schema exposure: consider disabling introspection in production for private APIs.
Choose GraphQL when many clients need different shapes of the same data, and REST when resources and access patterns are simple.
Q65.How should a Node.js service log?
Write structured JSON logs, one event per line, to standard output, and let the platform collect and ship them. Include consistent fields so logs can be searched and joined with traces.
- Fields: timestamp, level, message, service name, request ID, route and duration, plus any event-specific details.
- Levels: debug for diagnosis, info for business events, warn for recoverable problems, error for failures; run production at info.
- Performance: fast logging libraries write asynchronously and avoid expensive formatting;
console.logwith large objects can slow the event loop, and writes to a terminal or file may block. - Context: read the request ID from
AsyncLocalStorage, so every log line carries it automatically. - Errors: log the error object, including its stack and
cause, not just the message.
What not to log: passwords, tokens, authorization headers, full card numbers and unnecessary personal data. Configure redaction paths in the logger as a safety net.
Volume control: sample high-volume debug logs, and avoid logging every successful request body; access logs with timings are usually enough.
Q66.What metrics and health checks should a Node.js service expose?
Expose request metrics that show user experience, runtime metrics that show Node.js-specific saturation, and separate health checks for "is the process alive" and "can it serve traffic".
Request metrics:
- Request count, error count and a latency histogram, labeled by route template and status code.
- Outgoing call latency and errors per dependency.
Runtime metrics:
- Event loop delay and utilization, the clearest saturation signals for Node.js.
- Heap used, heap total and external memory, to catch leaks and buffer growth.
- Garbage collection counts and durations.
- Active handles and requests, such as open sockets and pending timers.
- Pool usage: database pool waiters and thread-pool saturation where measurable.
Health checks:
- Liveness answers quickly from memory without checking dependencies, so a database outage does not make the orchestrator restart every process.
- Readiness checks that the process can serve, such as having a working database pool, and fails during startup and shutdown so traffic goes elsewhere.
Keep label values bounded, never using user IDs or raw URLs, or the metrics store will be overwhelmed.
Q67.How do you test Node.js code with the built-in test runner?
Recent Node.js versions include
node:test, a test runner withtestanddescribefunctions, hooks, mocking and coverage, used together with thenode:assertmodule. Many projects no longer need a third-party test framework.- Writing tests:
test('name', async () => { ... })registers a test; returning or awaiting a promise lets async tests work naturally. - Grouping and hooks:
describegroups tests, andbefore,after,beforeEachandafterEachmanage setup. - Assertions:
assert.strictEqual,assert.deepStrictEqualandassert.rejectscover most checks; theassert/strictmodule makes strict comparisons the default. - Running:
node --testdiscovers test files by naming convention, runs them in separate processes and reports results. - Extras: watch mode, test filtering by name, built-in mocks for functions and timers, and code coverage reporting.
Good test habits:
- Keep unit tests fast and free of network calls by injecting dependencies.
- Test error paths, not just success.
- Make each test independent, so order and parallelism do not matter.
- Use realistic integration tests for database code, separate from the fast suite.
- Writing tests:
Q68.How do you mock dependencies in Node.js tests?
Prefer passing dependencies in, through factory functions or constructors, so tests can hand over simple fakes. When you need call tracking, the built-in
mock.fn()fromnode:testcreates functions that record every call and can return configured values.- Dependency injection first: a service created as
createOrderService({ repository, notify })can receive in-memory fakes with no special tooling. mock.fn: records arguments, results and call counts through itsmockproperty.mock.method: replaces a method on an existing object for the duration of a test and can restore it.- Timers:
mock.timerscontrolssetTimeout,setIntervalandDate, so time-based logic runs instantly. - Module mocking is possible but couples tests to how files import each other; reserve it for code you cannot restructure.
Avoid over-mocking: tests that assert every internal call break whenever the code is refactored, even when behavior is unchanged. Mock at boundaries such as databases, HTTP and email, and assert on outcomes where possible.
Example
A service receives mock functions, and the test checks the saved order and the notification.
const { mock } = require('node:test'); const assert = require('node:assert/strict'); function createOrderService({ repository, notify }) { return { async cancel(id) { const order = await repository.get(id); if (order.status === 'shipped') throw new Error('already shipped'); await repository.save({ ...order, status: 'cancelled' }); notify(`order ${id} cancelled`); }, }; } const repository = { get: mock.fn(async (id) => ({ id, status: 'new' })), save: mock.fn(async () => {}), }; const notify = mock.fn(); const service = createOrderService({ repository, notify }); service.cancel(7).then(() => { assert.equal(repository.save.mock.callCount(), 1); assert.deepEqual(repository.save.mock.calls[0].arguments[0], { id: 7, status: 'cancelled' }); console.log(notify.mock.calls[0].arguments[0]); // order 7 cancelled console.log(notify.mock.callCount()); // 1 });- Dependency injection first: a service created as
Q69.How do you test HTTP endpoints in a Node.js service?
Start the real application on a random free port inside the test process, send real HTTP requests to it, and assert on status codes, headers and bodies. This exercises routing, middleware, validation and serialization together.
- Build the app without listening: export a function that creates the server, so tests can start and stop their own instance.
- Listen on port 0, which lets the operating system pick a free port, and close the server after the tests.
- Use realistic dependencies: a real test database with migrations applied catches query and constraint errors that fakes miss.
- Isolate data: clean tables or use unique IDs per test, so tests can run in parallel.
- Cover failure paths: invalid input, missing authentication, permission denial and dependency timeouts.
- Check contracts: compare responses with the documented schema, so clients are not broken by accident.
Keep the suite fast and stable:
- Fake slow third-party APIs with a local stub server instead of calling them.
- Avoid fixed sleeps; wait for conditions or events.
- Run these tests in the pipeline on every change, alongside fast unit tests.
Q70.How do teams add static type checking to a Node.js codebase?
Most teams write code in a typed superset of JavaScript that a compiler checks and then converts, or strips, to plain JavaScript. Others keep plain JavaScript files and add type annotations in documentation comments that a type checker understands.
What static types catch before runtime:
- Calling functions with the wrong arguments or missing properties.
- Forgetting that a value may be
nullorundefined. - Misspelled property names and refactoring mistakes across many files.
Running typed code:
- Compile step: the build produces JavaScript files, with source maps so stack traces point to the original lines.
- Type stripping: recent Node.js versions can run files with type annotations directly by removing the types, which needs no build step but does not check types; type checking still runs separately in the pipeline.
Important limits:
- Types disappear at runtime, so request bodies and other external data still need runtime validation.
- Escape hatches such as
anysilently turn checking off. - Strict settings catch far more bugs than lenient ones; turn them on early in a project's life.
Schema libraries can derive both runtime validators and static types from one definition, closing the gap between them.
Q71.How do you create custom error classes and keep the original cause in Node.js?
Extend
Errorwith classes that carry a stable code and an HTTP status, setnameto the class name, and pass the original error as thecauseoption when wrapping a lower-level failure. Error handlers can then branch on type or code, while logs still show the root cause.- A base class:
AppErrorholdscode,statusand any safe details; subclasses such asNotFoundErrorset defaults. cause:new Error(message, { cause })is standard JavaScript; the full chain appears when the error is logged.- Stack traces: extending
Errorcaptures the stack automatically. instanceofchecks work across your code, but can fail when two copies of the same package are installed; checking acodeproperty is more robust.
Using them in an API:
- Throw domain errors in services, never HTTP responses.
- Map them to responses in one error middleware, sending the code and message but not the cause.
- Log the whole error, including
cause, with the request ID.
Never put secrets or raw database errors in messages that reach clients.
Example
A lower-level error is wrapped in a not-found error that carries a code, a status and the original cause.
class AppError extends Error { constructor(message, { code, status = 500, cause } = {}) { super(message, { cause }); this.name = this.constructor.name; this.code = code; this.status = status; } } class NotFoundError extends AppError { constructor(message, options = {}) { super(message, { status: 404, ...options }); } } function findOrder(id) { try { throw new Error(`row ${id} missing`); } catch (error) { throw new NotFoundError(`order ${id} not found`, { code: 'order_not_found', cause: error }); } } try { findOrder(42); } catch (error) { console.log(error instanceof AppError, error.name, error.status); // true NotFoundError 404 console.log(error.code); // order_not_found console.log(error.cause.message); // row 42 missing }- A base class:
Q72.What does the Node.js permission model do, and when would you use it?
Recent Node.js versions include a permission model, turned on with a command-line flag, that blocks access to sensitive capabilities unless you grant them explicitly. With it enabled, the process cannot read or write files, start child processes, create worker threads or load native add-ons outside what you allow.
- Granting access: separate flags allow reading or writing specific file paths, and enable child processes, workers or add-ons when needed.
- Runtime checks: code can ask whether a permission is granted, and denied operations throw clear errors.
- What it is: a "seat belt" that stops trusted code, including your dependencies, from accidentally touching things it should not.
- What it is not: a sandbox for hostile code; the documentation states it does not provide security guarantees against malicious code.
When to use it:
- Services that should never write to disk or start processes, where an unexpected attempt signals a bug or a compromised dependency.
- Command-line tools that process untrusted files and should only read one directory.
Combine it with operating system controls, such as a read-only file system and a non-root user, which enforce limits outside the process itself.
Q73.How do you debug a Node.js service in development and in production?
In development, attach a debugger and step through code; in production, rely on structured logs, metrics, traces and diagnostic snapshots, because pausing a live process stops every request it is serving.
Development:
- Start Node.js with
--inspectand connect a debugger from an editor or browser developer tools to set breakpoints and inspect variables. - Use
--inspect-brkto pause on the first line, for debugging startup code. - Enable source maps, so stack traces and breakpoints map to the original source when code is compiled.
- Set
NODE_DEBUGfor verbose output from specific core modules such ashttp.
Production:
- Logs and traces with request IDs locate the failing request and the slow step.
- Diagnostic reports: Node.js can write a JSON report with stacks, heap statistics and resource usage on a fatal error or on a signal.
- Heap snapshots and CPU profiles on a single instance removed from traffic diagnose leaks and hot spots.
- Core dumps capture the full process after a crash, for deep native issues.
Never expose the inspector port publicly; anyone who can reach it can run code in the process.
- Start Node.js with
Q74.How do fake timers help test time-based code in Node.js?
Fake timers replace
setTimeout,setIntervaland optionallyDatewith controllable versions, so a test can move time forward instantly instead of waiting. Tests for expiry, retries, debouncing and scheduling become fast and deterministic.- Enable and tick: the test enables fake timers, then advances time by a number of milliseconds; any timers due in that period fire synchronously.
- Fake
Date: controlling the clock as well makes logic based on timestamps, such as token expiry, testable. - Reset: restore real timers after each test, so tests do not affect each other.
What to test this way:
- Session or cache expiry after inactivity.
- Retry schedules with backoff.
- Rate limiter windows.
- Timeouts that should fire, and ones that should be cancelled.
Alternative without fakes: inject a clock function and a scheduler into the code, so tests can pass their own. This is often cleaner for business logic, and keeps fake timers for code you cannot easily change.
Example
A session that resets its 30-second expiry on activity is tested in an instant with fake timers.
const { mock } = require('node:test'); mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 }); function createSession(ttlMs, onExpire) { let timer = setTimeout(onExpire, ttlMs); return { touch() { clearTimeout(timer); timer = setTimeout(onExpire, ttlMs); }, }; } const expired = []; const session = createSession(30_000, () => expired.push(Date.now())); mock.timers.tick(20_000); session.touch(); mock.timers.tick(20_000); console.log(expired.length); // 0 mock.timers.tick(10_000); console.log(expired.length); // 1 console.log(expired[0]); // 50000 mock.timers.reset();Q75.How do you handle CORS correctly in a Node.js API?
Cross-origin resource sharing lets a browser page from one origin call your API on another. Configure it to allow only the specific origins, methods and headers your frontends need, and treat it as a browser rule, not as authentication.
- Allowlist origins: compare the request's
Originheader with a configured list, and echo back only a matching origin. Never reflect any origin while also allowing credentials. - Credentials: cookies are sent cross-origin only when the client opts in and the server returns
Access-Control-Allow-Credentials: true; a wildcard origin is not allowed in that case. - Preflight requests: browsers send an
OPTIONSrequest before many cross-origin calls; answer it quickly, and set a max-age so browsers cache the result. Vary: Origin: when the allowed origin header changes per request, tell caches the response depends on the origin.- Allowed headers and methods: list only what the frontend uses.
What CORS does not do: it does not stop non-browser clients, such as scripts, from calling your API, so authentication and authorization are still required. For cookie-based sessions, also protect state-changing requests against cross-site request forgery.
- Allowlist origins: compare the request's
Q76.How do you package and run a Node.js service in a container?
Build a small image with only production dependencies installed from the lock file, run it as a non-root user, and start
nodedirectly as the main process so it receives shutdown signals.- Multi-stage build: install all dependencies and compile in a build stage, then copy only the output and production dependencies into a slim final image.
- Clean install from the lock file, omitting development dependencies, so the image contains exactly the reviewed versions.
- Copy dependency manifests first, then the source, so code changes reuse the cached dependency layer.
- Set
NODE_ENV=production, which many libraries use to enable optimizations and disable debug behavior. - Run as a non-root user, which official Node.js base images already provide.
- Start Node.js directly: some package manager script runners do not forward signals, so graceful shutdown never runs.
- Exclude local files such as tests, local environment files and installed modules from the build context with an ignore file.
- Set memory flags that match the container limit.
If the service spawns child processes, add a minimal init process so finished children are reaped and signals are forwarded correctly.
Q77.How do you set the Node.js heap size relative to a container's memory limit?
Set the old-generation heap limit with
--max-old-space-sizeto roughly 70% to 80% of the container's memory limit, leaving the rest for everything outside the JavaScript heap. If the heap limit is too high, the container is killed for exceeding its memory before the engine's own collector reacts.Memory a Node.js process uses besides the old-generation heap:
- The young generation, where new objects start.
- Buffers and other external memory, which can be large for file, network and compression work.
- Compiled code, thread stacks and the thread pool.
- Native add-ons and memory allocator overhead.
Why set it explicitly:
- The default limit depends on the Node.js version and the memory the runtime detects, and older versions did not account for container limits.
- An explicit value makes behavior predictable across environments.
Verify under load: watch resident memory against the limit during a realistic test. Services that handle many large Buffers need a lower heap share; services with mostly small objects can use a higher one.
Example
A container has a 1 GiB memory limit, which is 1,024 MiB.
- At 75%, the heap limit is
0.75 × 1,024 = 768MiB, set with--max-old-space-size=768. - That leaves about 256 MiB for the young generation, Buffers, code and native memory.
- A load test shows resident memory peaking at 930 MiB, safely below 1,024.
- If the service handled large file uploads in memory, the external Buffers would need more room, so the heap limit would drop to around 640 MiB.
Q78.A Node.js service's latency rises while one CPU core sits at 100%. How do you investigate?
One saturated core with rising latency across all endpoints almost always means the event loop is busy with synchronous work. Find which code holds the main thread, then move it off the request path or make it cheaper.
- Confirm saturation: event loop delay and utilization should both be high; if delay is low, look at downstream dependencies instead.
- Check recent changes: a release that added a large serialization, a new regular expression or a synchronous call is the usual suspect.
- Capture a CPU profile on one instance under load and read the flame graph from the request handlers down.
- Look for the classic culprits: synchronous crypto, compression or file calls; huge
JSON.stringifyorJSON.parse; backtracking regular expressions; loops over large arrays; heavy logging. - Fix it: use asynchronous APIs, stream large payloads, paginate, cache results, or move computation to worker threads.
- Scale as a stopgap: more processes help only if the expensive work is spread across requests, not concentrated in one.
Example
After a release, the 95th percentile latency of every endpoint jumps from 80 ms to 1.4 seconds.
- Event loop delay is above 900 ms, and one core is at 100% on each process.
- A CPU profile shows most time in a password-check function on the login route.
- The release had switched from
crypto.scrypttocrypto.scryptSyncto simplify the code, so each login blocked the loop for about 90 ms. - Restoring the asynchronous version moves hashing to the thread pool, and latency returns to normal.
Q79.Users see intermittent 502 errors from a load balancer in front of Node.js. What could cause it?
A common cause is a keep-alive timeout mismatch: the load balancer keeps idle connections to Node.js open longer than Node.js does. Node.js closes an idle connection, the load balancer reuses it a moment later, the request fails, and the load balancer reports a 502.
sequenceDiagram participant L as Load balancer participant N as Node.js server L->>N: Request on a kept-alive connection N-->>L: Response N->>N: Idle timeout passes, server closes socket L->>N: Next request reuses the closed connection N-->>L: Connection reset L->>L: Returns 502 to the clientHow to confirm it:
- The errors are rare, happen under moderate traffic after idle gaps, and do not appear in Node.js access logs, because the request never reached the handler.
- Load balancer logs show connection resets from the backend.
How to fix it:
- Set
server.keepAliveTimeouthigher than the load balancer's idle timeout. - Set
server.headersTimeoutslightly higher than the keep-alive timeout. - Check every layer, including proxies and ingress controllers, since each has its own timeout.
Interview tip: stating that the backend must keep idle connections open longer than the proxy in front of it shows real production experience.
Example
A load balancer has a 60-second idle timeout, while Node.js uses its default keep-alive timeout of 5 seconds.
- After a request, the connection sits idle, and Node.js closes it at 5 seconds.
- If the load balancer sends the next request on that connection at 5.01 seconds, it hits a closed socket and returns a 502.
- Setting
keepAliveTimeoutto 65,000 ms andheadersTimeoutto 66,000 ms makes the load balancer always close idle connections first. - The 502 errors disappear.
Q80.Calls from Node.js to another service fail with connection resets. How do you handle that?
Intermittent resets on outgoing calls often come from reusing a pooled keep-alive socket that the other side has just closed, from overloaded servers dropping connections, or from network devices killing idle connections.
- Check the timing: resets right after idle periods point to stale keep-alive sockets; resets under heavy load point to the server or a proxy rejecting connections.
- Align idle timeouts: make the client's idle socket timeout shorter than the server's keep-alive timeout, so the client discards sockets before the server closes them.
- Retry once for safe requests: a reset on a reused socket before any response is safe to retry for idempotent methods.
- Bound the pool: too many concurrent sockets can hit the other side's connection limits.
- Check intermediaries: firewalls and network address translation devices silently drop long-idle connections; TCP keep-alive probes or shorter idle times help.
- Monitor: track resets, retries and socket counts per dependency, so a rising rate is caught before users notice.
Do not blindly retry non-idempotent requests such as payments, because the server may have processed the request before the connection dropped. Use idempotency keys for those.