Ch. 06

Node.js

JavaScript on the server: event loop phases, streams, modules, workers, error handling and performance.

58 interview questions29 quiz questions1 notes
your progress0%

Notes in this chapter

Filter all notes →
read ✓Node.js · hard

The Node.js Event Loop Phases, Explained

What libuv does on each turn of the loop, where nextTick, promises, setImmediate and timers really run, and how to keep CPU-heavy work from blocking everything.

~7 min readread →

Top 58 Node.js interview questions most asked first

  1. 1.What is Node.js, and how can a single-threaded runtime handle thousands of concurrent connections?easy

    Node.js is a JavaScript runtime built on Google's V8 engine plus libuv, a C library that provides the event loop, asynchronous I/O and a small thread pool. It lets you run JavaScript outside the browser, with APIs for files, networking, processes and more.

    Your JavaScript runs on one main thread, but I/O doesn't block it. When you read from a socket or a file, Node hands the work to the operating system (epoll, kqueue, IOCP) or to libuv's thread pool and moves on. When the result is ready, a callback is queued and the event loop runs it.

    So a connection that's waiting on a database costs almost nothing: no thread is parked on it. That's why Node shines at I/O-bound work like APIs, proxies and real-time apps. The trade-off is that CPU-heavy code blocks every request, so it belongs in worker threads or a separate service.

    What interviewers listen for
    • V8 runs JS; libuv provides the event loop and async I/O
    • JavaScript runs on a single main thread
    • I/O is delegated to the OS or the thread pool
    • Completed I/O queues callbacks for the event loop
    • Great for I/O-bound work, poor for CPU-bound work

    Likely follow-up: What happens if you block the event loop? · Is Node.js really single-threaded?

  2. 2.Walk me through the phases of the Node.js event loop.mid

    After the main script runs, libuv's loop repeats a fixed cycle of phases, each with its own callback queue:

    • timers: expired setTimeout and setInterval callbacks
    • pending callbacks: some system callbacks deferred from the previous iteration, such as certain TCP errors
    • idle, prepare: internal use only
    • poll: retrieves new I/O events and runs their callbacks; most of your code runs here
    • check: setImmediate callbacks
    • close callbacks: handlers like socket.on('close') after a destroy()

    When there's nothing to do, the loop waits in poll for I/O, but only until the nearest timer is due, and it doesn't wait at all if immediates are queued. process.nextTick and promise callbacks aren't phases: Node drains those queues after every individual callback. When no active handles or requests remain, the loop exits and the process ends.

    What interviewers listen for
    • Timers, pending, idle/prepare, poll, check, close
    • Poll waits for I/O, bounded by the next timer
    • setImmediate runs in the check phase
    • nextTick and microtasks drain after every callback
    • Loop exits when no active handles remain

    Likely follow-up: Why does setImmediate always run before setTimeout(fn, 0) inside an I/O callback?

  3. 3.How do process.nextTick, promise callbacks, setImmediate and setTimeout(fn, 0) differ? What does this CommonJS script print?mid

    They go to different queues:

    • process.nextTick callbacks run as soon as the current operation finishes, before anything else, including promises.
    • Promise callbacks (and queueMicrotask) are microtasks, drained right after the nextTick queue.
    • setTimeout(fn, 0) goes to the timers phase; a delay of 0 is really 1 ms.
    • setImmediate goes to the check phase, right after poll.

    So it prints sync, nextTick, promise, then timeout and immediate in either order: from the main module it depends on whether 1 ms has passed when the loop starts. Inside an I/O callback, setImmediate always wins, because check comes straight after poll.

    Gotcha: saved as an ES module, it prints promise before nextTick. ES modules are evaluated asynchronously, from inside a microtask, so V8 drains pending promise callbacks before Node gets back to the nextTick queue.

    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
    Promise.resolve().then(() => console.log('promise'));
    process.nextTick(() => console.log('nextTick'));
    console.log('sync');
    What interviewers listen for
    • nextTick queue runs before promise microtasks
    • Both queues drain after every callback
    • setTimeout(fn, 0) is the timers phase, at least 1 ms
    • setImmediate runs in the check phase, after poll
    • Timeout vs immediate order is non-deterministic in main module

    Likely follow-up: What happens if you call process.nextTick recursively?

  4. 4.What does this CommonJS script print, and why?hard

    It prints script start, a start, b, script end, tick, a end, then, timeout.

    • Synchronous code runs first. Calling a() runs its body synchronously up to the first await, and b() runs synchronously too, so a start and b print before script end.
    • await b() suspends a. Because b() returns a native promise that's already resolved, the rest of a is queued as a microtask right away, before the .then callback registered on the next line.
    • When the script finishes, Node drains the process.nextTick queue first (tick), then the promise microtasks in FIFO order (a end, then).
    • Only then does the event loop start and run the timer (timeout).

    Run the same code as an ES module and tick moves after then: module evaluation is itself asynchronous, so promise microtasks drain before Node processes the nextTick queue.

    async function a() {
      console.log('a start');
      await b();
      console.log('a end');
    }
    async function b() {
      console.log('b');
    }
    console.log('script start');
    setTimeout(() => console.log('timeout'), 0);
    a();
    Promise.resolve().then(() => console.log('then'));
    process.nextTick(() => console.log('tick'));
    console.log('script end');
    What interviewers listen for
    • Async functions run synchronously until the first await
    • Code after await resumes as a microtask
    • In CommonJS, nextTick drains before promise microtasks
    • Timers run only after all microtasks
    • In ESM, promises drain before nextTick
  5. 5.What are the differences between CommonJS and ES modules in Node.js, and how does Node decide which format a file uses?easy

    CommonJS uses require() and module.exports. Loading is synchronous, require can be called anywhere (even conditionally), and each module gets __dirname and __filename.

    ES modules use import and export. Static imports are hoisted and analyzable, which enables tree-shaking; loading is asynchronous, top-level await works, code is always in strict mode, and exports are live bindings. Relative imports need the full file extension, like ./utils.js.

    Node picks the format per file:

    • .mjs is always ESM and .cjs is always CommonJS.
    • .js follows the nearest package.json: "type": "module" means ESM, otherwise CommonJS.
    • Node 22 also detects ESM syntax in a .js file with no type and reparses it as ESM, with a warning, but you should set type explicitly.

    For new code, ESM is the standard.

    What interviewers listen for
    • require is synchronous and dynamic; import is static
    • ESM has top-level await, strict mode, live bindings
    • .mjs and .cjs force the format
    • .js follows the nearest package.json type field
    • ESM needs file extensions and has no __dirname

    Likely follow-up: Can a CommonJS file require() an ES module?

  6. 6.What does it mean to block the event loop, what typically causes it, and how do you avoid it?mid

    Blocking means one piece of synchronous JavaScript holds the main thread so long that the loop can't run anything else: no other requests, no timers, no I/O callbacks. In the snippet, a single request to /slow freezes the whole server for five seconds.

    Common causes:

    • CPU-heavy work: hashing, image processing, big loops, sorting huge arrays
    • JSON.parse or JSON.stringify on very large payloads
    • *Sync APIs like fs.readFileSync or crypto.pbkdf2Sync in the request path
    • Catastrophic regex backtracking (ReDoS) on user input

    Fixes: move CPU work to worker_threads or a job queue, use async and streaming APIs, split long loops into chunks with setImmediate so I/O can interleave, and cap input sizes. To detect it, track event loop delay with perf_hooks.monitorEventLoopDelay() or a profiler.

    import http from 'node:http';
    
    http.createServer((req, res) => {
      if (req.url === '/slow') {
        const end = Date.now() + 5000;
        while (Date.now() < end) {} // every other request now waits 5 s
      }
      res.end('ok');
    }).listen(3000);
    What interviewers listen for
    • Sync code monopolizes the single JS thread
    • All requests, timers and I/O callbacks stall
    • Causes: CPU work, huge JSON, *Sync APIs, ReDoS
    • Offload to worker threads or chunk with setImmediate
    • Measure event loop delay to detect it

    Likely follow-up: How would you process a 1 GB JSON file without blocking?

  7. 7.What is the difference between module.exports and exports in CommonJS?easy

    Node wraps every CommonJS file in a function, roughly (function (exports, require, module, __filename, __dirname) { ... }). exports is just a local parameter that starts out pointing at the same object as module.exports.

    require() always returns module.exports. Adding properties to exports works because you're mutating that shared object. But reassigning exports only rebinds the local variable: the link is broken and callers get whatever module.exports still holds, which in b.js is an empty object.

    Rule of thumb: use exports.name = ... for several named exports, and module.exports = ... when you export a single function, class or object. Don't mix the two styles in one file.

    // a.js: works, mutates the shared object
    exports.add = (a, b) => a + b;
    
    // b.js: broken, require('./b') returns {}
    exports = { add: (a, b) => a + b };
    
    // c.js: works
    module.exports = { add: (a, b) => a + b };
    What interviewers listen for
    • exports starts as a reference to module.exports
    • require() returns module.exports
    • Reassigning exports breaks the link
    • Each module is wrapped in a function scope

    Likely follow-up: Where do require, __dirname and module come from in a CommonJS file?

  8. 8.What are streams in Node.js, and what are the four types of stream?mid

    A stream processes data piece by piece, in chunks, instead of loading it all into memory. That keeps memory flat for large files or responses and lets you start sending output before the input is finished.

    The four types:

    • Readable: a source, like fs.createReadStream or an incoming HTTP request
    • Writable: a destination, like fs.createWriteStream or an HTTP response
    • Duplex: readable and writable, with independent sides, like a TCP socket
    • Transform: a duplex whose output is computed from its input, like zlib.createGzip() or a CSV parser

    Streams are EventEmitters: readables emit data, end and error; writables emit drain and finish. They carry Buffers or strings by default, or any JavaScript value in objectMode. You connect them with pipe() or, better, stream.pipeline(), which also handles errors and cleanup.

    What interviewers listen for
    • Process data in chunks; memory stays constant
    • Readable, Writable, Duplex, Transform
    • Examples: files, HTTP req/res, sockets, gzip
    • Streams are EventEmitters
    • Connect with pipeline() for errors and backpressure

    Likely follow-up: What is backpressure and how do streams handle it?

  9. 9.How do you handle errors in Node.js across callbacks, promises and async/await? What are uncaughtException and unhandledRejection for?mid

    It depends on the API style:

    • Callbacks use the error-first convention: (err, result) => { if (err) return handle(err); ... }. A try/catch around the call won't help, because the callback runs later.
    • Promises and async/await: await inside try/catch, or attach .catch(). Every chain needs a handler somewhere.
    • EventEmitters and streams emit an 'error' event; if nobody listens, it's thrown and crashes the process.

    process.on('uncaughtException') fires for a thrown error nothing caught, and process.on('unhandledRejection') for a rejected promise with no handler. Since Node 15, an unhandled rejection crashes the process by default, just like an uncaught exception.

    Use those process handlers only as a last resort to log and exit, because the app may be in an unknown state; let a process manager restart it. Also separate operational errors (timeouts, bad input), which you handle, from programmer errors (bugs), which you fix.

    What interviewers listen for
    • Error-first callbacks: check err first
    • try/catch around await, or .catch()
    • Unhandled 'error' events crash the process
    • Unhandled rejections crash by default since Node 15
    • Process-level handlers: log, exit, restart

    Likely follow-up: Why is it unsafe to keep running after an uncaughtException?

  10. 10.What is middleware in Express, and how is error-handling middleware different from regular middleware?easy

    Middleware is a function (req, res, next) in the request pipeline. Express runs middleware in the order it was registered; each one can read or modify req and res, end the response, or call next() to pass control on. If it does neither, the request hangs. Typical uses are body parsing, logging, auth, CORS and rate limiting.

    Error-handling middleware takes four arguments, (err, req, res, next), and Express recognizes it by that arity. When a handler throws or calls next(err), Express skips the remaining regular middleware and jumps to the next error handler, so register error handlers last, after your routes.

    In Express 4 you had to catch async errors yourself and call next(err). Express 5 automatically forwards rejected promises from async handlers to the error middleware.

    const app = express();
    app.use(express.json()); // built-in body parser
    app.use((req, res, next) => {
      console.log(req.method, req.url);
      next(); // pass control to the next middleware
    });
    app.get('/users/:id', async (req, res) => {
      const user = await db.findUser(req.params.id); // Express 5 forwards rejections
      res.json(user);
    });
    app.use((err, req, res, next) => {
      // error handler: recognized by its four arguments
      res.status(err.status ?? 500).json({ error: err.message });
    });
    What interviewers listen for
    • Signature (req, res, next), runs in registration order
    • Must call next() or end the response
    • Error middleware has four arguments, registered last
    • next(err) skips straight to error handlers
    • Express 5 forwards async rejections automatically

    Likely follow-up: How would you write an authentication middleware?

  11. 11.Is Node.js really single-threaded? Explain the libuv thread pool and which operations use it.hard

    Your JavaScript runs on one thread, but the process isn't single-threaded. V8 uses helper threads for garbage collection and compilation, and libuv keeps a thread pool for work the OS can't do asynchronously.

    The pool has 4 threads by default, configurable with the UV_THREADPOOL_SIZE environment variable (maximum 1024) when the process starts. It's used by:

    • Most fs operations
    • dns.lookup(), which http.get and net.connect use to resolve hostnames
    • Async crypto such as pbkdf2, scrypt, randomBytes and generateKeyPair
    • Async zlib compression

    Network I/O on TCP and HTTP sockets does not use the pool; it relies on OS mechanisms like epoll and kqueue. In the snippet, hashes five and six wait for a free thread. The same queuing happens when many slow file or DNS calls pile up, which shows up as mysterious latency.

    import { pbkdf2 } from 'node:crypto';
    
    const start = Date.now();
    for (let i = 1; i <= 6; i++) {
      pbkdf2('pw', 'salt', 200_000, 64, 'sha512', () => {
        console.log(i, Date.now() - start, 'ms');
      });
    }
    // Default pool: four finish together, the last two take about twice as long.
    // With UV_THREADPOOL_SIZE=6, all six finish together.
    What interviewers listen for
    • JS runs on one thread; the process has several
    • libuv pool has 4 threads by default
    • Tune with UV_THREADPOOL_SIZE at startup
    • Used by fs, dns.lookup, async crypto, zlib
    • Network sockets use OS async I/O, not the pool

    Likely follow-up: Why might raising UV_THREADPOOL_SIZE not make things faster?

  12. 12.When would you use worker_threads, child_process or cluster?mid

    All three add parallelism, at different levels:

    • worker_threads run JavaScript on extra threads inside the same process. Each worker has its own V8 isolate, heap and event loop. They talk via postMessage (structured clone), can transfer ArrayBuffers without copying, and can share memory through SharedArrayBuffer. Use them for CPU-heavy JavaScript: parsing, image resizing, hashing.
    • child_process starts a separate OS process, which can be any program. spawn streams output, exec runs a shell command and buffers the output, execFile skips the shell, and fork starts another Node script with an IPC channel. Use it for external tools like ffmpeg or git, or for full isolation.
    • cluster forks several copies of your server that share one port, and the primary process distributes incoming connections among them. Use it to put every CPU core to work on HTTP traffic.

    In short: workers for CPU tasks, child processes for other programs, cluster for scaling a server.

    import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
    
    if (isMainThread) {
      const worker = new Worker(new URL(import.meta.url), { workerData: 35 });
      worker.on('message', (n) => console.log('fib =', n)); // fib = 9227465
      worker.on('error', console.error);
    } else {
      const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
      parentPort.postMessage(fib(workerData)); // runs off the main thread
    }
    What interviewers listen for
    • Workers: threads in one process, own isolate and loop
    • Workers suit CPU-heavy JS and can share memory
    • child_process: a separate OS process, any program
    • spawn, exec, execFile, fork differ in shell and output
    • cluster: several server processes sharing one port

    Likely follow-up: Why not create a new worker for every request?

  13. 13.What is the difference between npm and npx?easy

    npm is the package manager. It installs, updates and removes packages, maintains package.json and the lockfile, runs scripts with npm run <name>, and publishes packages to the registry.

    npx runs a package's executable. If the binary is in the project's node_modules/.bin, it uses that; otherwise it downloads the package into npm's cache and runs it without adding it to your project. Since npm 7, npx is essentially npm exec, and it asks for confirmation before installing something that isn't already available.

    Typical uses:

    • One-off generators: npx create-vite@latest my-app
    • A local tool without a global install: npx eslint .
    • A specific version: npx prettier@3 --check .

    Inside package.json scripts you don't need npx at all, because npm run already puts node_modules/.bin on the PATH.

    What interviewers listen for
    • npm installs and manages packages and scripts
    • npx executes a package binary
    • npx prefers the local node_modules/.bin
    • Otherwise it fetches to the cache, not the project
    • npm run scripts already see local binaries
  14. 14.What are the most important fields in package.json?easy

    The ones I use and look for most:

    • name and version: the package's identity, required if you publish.
    • scripts: commands run with npm run <name>, like dev, build and test; npm test and npm start are shortcuts.
    • dependencies, devDependencies, peerDependencies: what the package needs, as semver ranges.
    • type: "module" makes .js files ES modules; without it they're CommonJS.
    • main is the legacy entry point; exports is the modern one. exports defines exactly which paths consumers can import and can point import and require at different files (conditional exports).
    • bin: executables to link onto the PATH, for CLIs.
    • engines: supported Node versions, like ">=22".
    • files: what gets published.
    • private: true: blocks accidental publishing, common for apps.

    Also handy: workspaces for monorepos, imports for internal # aliases, and packageManager for Corepack.

    What interviewers listen for
    • name, version and scripts
    • Dependency fields hold semver ranges
    • "type": "module" switches .js to ESM
    • exports defines the public entry points
    • bin, engines, files, private

    Likely follow-up: What does the exports field prevent that main did not?

  15. 15.What is the difference between dependencies, devDependencies and peerDependencies?easy
    • dependencies are needed at runtime, like express or pg. They're installed wherever your package is installed.
    • devDependencies are only needed to develop, build or test, like typescript, vitest or eslint. They aren't installed when someone else installs your package, and npm install --omit=dev skips them in production.
    • peerDependencies say "I work with your copy of this package" instead of bringing my own. Plugins and component libraries use them: a React component library declares react as a peer so the app ends up with exactly one React. Since npm 7, peers are installed automatically, and an incompatible version fails the install unless you pass --legacy-peer-deps.

    There's also optionalDependencies, whose install failures are tolerated. For a bundled app, the split mostly documents intent; for a published library, it decides what your users download.

    What interviewers listen for
    • dependencies: needed at runtime
    • devDependencies: build, test and lint tooling
    • --omit=dev skips dev dependencies in production
    • peerDependencies: the host provides one shared copy
    • npm 7+ installs peers automatically
  16. 16.Explain semantic versioning, the ^ and ~ ranges, and why lockfiles matter.easy

    Semver versions are MAJOR.MINOR.PATCH: bump major for breaking changes, minor for backward-compatible features, patch for bug fixes.

    Ranges in package.json:

    • ^1.4.2 means >=1.4.2 <2.0.0: any compatible minor or patch. It's what npm install saves by default.
    • ~1.4.2 means >=1.4.2 <1.5.0: patches only.
    • Below 1.0 the caret is stricter: ^0.4.2 means <0.5.0, because 0.x minors may break.

    Ranges mean two installs a month apart can get different versions. package-lock.json records the exact version, source and integrity hash of every package in the tree, including transitive ones, so every machine and CI run gets the same node_modules. Commit it, and use npm ci in CI: it installs exactly what the lockfile says and fails if the lockfile and package.json disagree.

    What interviewers listen for
    • MAJOR.MINOR.PATCH: breaking, feature, fix
    • ^ allows minor and patch; ~ only patch
    • The caret is stricter for 0.x versions
    • Lockfile pins exact versions of the whole tree
    • Commit the lockfile; use npm ci in CI

    Likely follow-up: What is the difference between npm install and npm ci?

  17. 17.What is the EventEmitter, and are its listeners called synchronously or asynchronously? What does this print?easy

    EventEmitter from node:events implements the observer pattern and underpins much of Node: streams, HTTP servers, sockets and process are all emitters. You subscribe with on, subscribe for a single call with once, unsubscribe with off, and trigger with emit(name, ...args).

    emit() is synchronous: it calls every listener in registration order before returning. The snippet prints before, send receipt 42, first order! 42, send receipt 43, after. A listener that needs to defer work has to schedule it itself, for example with setImmediate.

    Two details interviewers look for:

    • The 'error' event is special: emitting it with no listener throws, which usually crashes the process.
    • Adding more than 10 listeners for one event prints a MaxListenersExceededWarning, a hint that you may be leaking listeners.

    events.once(emitter, name) returns a promise, handy with await.

    import { EventEmitter } from 'node:events';
    
    const orders = new EventEmitter();
    orders.on('paid', (id) => console.log('send receipt', id));
    orders.once('paid', (id) => console.log('first order!', id));
    
    console.log('before');
    orders.emit('paid', 42);
    orders.emit('paid', 43);
    console.log('after');
    What interviewers listen for
    • Observer pattern behind streams, servers and process
    • on, once, off, emit
    • emit() calls listeners synchronously, in order
    • An unhandled 'error' event throws
    • More than 10 listeners triggers a leak warning
  18. 18.What is a Buffer in Node.js, and why does it exist?easy

    A Buffer is a fixed-length sequence of raw bytes. JavaScript strings are text, but files, sockets, images and crypto all deal in binary data, so Node needed a byte type. Today Buffer is a subclass of Uint8Array.

    Key APIs:

    • Buffer.from('héllo', 'utf8') or Buffer.from(arrayBuffer) to create one
    • Buffer.alloc(size) gives zero-filled memory; Buffer.allocUnsafe(size) is faster but may contain old data, so only use it when you overwrite every byte
    • buf.toString('base64') (or 'hex', 'utf8') to convert back
    • Buffer.concat([a, b]) to join chunks

    Characters aren't bytes: 'é'.length is 1, but Buffer.byteLength('é') is 2. So when you collect a stream's chunks, don't decode each chunk separately, because a multi-byte character can be split across two chunks. Concatenate the Buffers first, or call setEncoding('utf8') on the stream.

    What interviewers listen for
    • Fixed-length raw bytes; a Uint8Array subclass
    • Used for files, sockets, crypto, binary protocols
    • alloc zero-fills; allocUnsafe may hold old data
    • Characters are not bytes: use byteLength
    • Decode after concatenating, not per chunk
  19. 19.What is the difference between fs.readFileSync, fs.readFile and fs/promises, and when would you use each?easy

    All three read the same file; they differ in how they wait.

    • readFileSync blocks the event loop until the file is read. That's fine in startup code and CLI scripts, like loading config once, but never in a request handler, where it stalls every other request.
    • fs.readFile is non-blocking: the work runs on libuv's thread pool and the result arrives in an error-first callback.
    • node:fs/promises offers the same non-blocking operations returning promises, so you can use async/await and try/catch. It's the default choice in modern code.

    All of them load the whole file into memory. For large files, or to send a file over HTTP, use fs.createReadStream and pipe it, so memory stays constant. Also avoid check-then-act code like existsSync followed by a read, which races: just attempt the operation and handle ENOENT.

    import fs from 'node:fs';
    import { readFile } from 'node:fs/promises';
    
    const a = fs.readFileSync('config.json', 'utf8'); // blocks until done
    
    fs.readFile('config.json', 'utf8', (err, b) => { // error-first callback
      if (err) return console.error(err);
    });
    
    const c = await readFile('config.json', 'utf8'); // promise, works with await
    What interviewers listen for
    • Sync APIs block the event loop
    • Sync is fine at startup and in CLIs
    • Callback and promise APIs run on the thread pool
    • Prefer fs/promises with async/await
    • Stream large files instead of reading them whole
  20. 20.What is backpressure in streams, and why is stream.pipeline() preferred over .pipe()?hard

    Backpressure is what happens when a producer is faster than its consumer. Every writable has an internal buffer sized by highWaterMark (64 KiB by default for byte streams in Node 22). write() returns false once the buffer is past that limit; the producer should stop and wait for the 'drain' event, as the snippet does. Ignore it and data piles up in memory until the process runs out.

    readable.pipe(writable) handles backpressure for you by pausing and resuming the source. What it doesn't handle is errors: if one stream fails, the others aren't destroyed, so you can leak file descriptors or leave sockets hanging, and you need an error listener on every stream.

    pipeline(a, b, c) from node:stream/promises handles backpressure and errors: if any stream fails, it destroys all of them and rejects once, so a single try/catch around await pipeline(...) covers everything.

    import { once } from 'node:events';
    import { createWriteStream } from 'node:fs';
    
    const out = createWriteStream('big.txt');
    for (let i = 0; i < 1e6; i++) {
      if (!out.write(`line ${i}\n`)) {
        await once(out, 'drain'); // buffer is full: wait until it empties
      }
    }
    out.end();
    What interviewers listen for
    • A fast producer fills memory if unchecked
    • write() returns false past highWaterMark
    • Wait for 'drain' before writing more
    • .pipe() handles backpressure but not errors
    • pipeline() destroys every stream on error

    Likely follow-up: How would you gzip a large file and upload it without buffering it in memory?

  21. 21.Compare session-based authentication with JWTs. Which would you choose for a web app?mid

    With sessions, the server stores session data (in memory, Redis or a database) and gives the browser a random session ID in a cookie. Each request looks that ID up. Revoking is easy, just delete the session, but every server instance needs access to the shared session store.

    A JWT is a signed token, header.payload.signature, carrying claims such as the user ID and expiry. Any server with the key can verify it without a lookup, which suits distributed services and APIs. The downsides: you can't easily revoke a token before it expires, and the payload is only base64url-encoded, not encrypted, so never put secrets in it. The usual mitigation is short-lived access tokens plus a revocable refresh token.

    For a classic web app I'd default to sessions in an HttpOnly, Secure, SameSite cookie. JWTs fit stateless APIs, mobile clients and service-to-service auth. Either way, avoid localStorage for tokens, since any XSS can read it.

    What interviewers listen for
    • Sessions: server-side state, ID in a cookie
    • Sessions are easy to revoke; need a shared store
    • JWT: signed, self-contained, verified without lookup
    • JWT payload is encoded, not encrypted
    • Short-lived access tokens plus refresh tokens

    Likely follow-up: How would you log a user out everywhere if you use JWTs?

  22. 22.How do you manage environment variables and configuration in a Node.js app? Do you still need dotenv?easy

    Configuration that changes between environments, like ports, database URLs, API keys and feature flags, should come from environment variables, read through process.env. Values are always strings, so parse numbers and booleans yourself, and validate everything once at startup so a missing variable fails fast instead of at 3 a.m.

    For local development Node can load .env files itself, so dotenv is optional:

    • --env-file=.env loads a file; pass it several times and later files override earlier ones.
    • --env-file-if-exists doesn't fail when the file is missing (Node 22.9+).
    • process.loadEnvFile('.env') does the same from code.

    Variables already set in the real environment take precedence over values from the file. Keep .env out of git and commit a .env.example instead; in production, inject secrets from the platform or a secret manager. Expose the parsed, typed config from one module rather than reading process.env all over the codebase.

    # .env
    PORT=3000
    DATABASE_URL="postgres://localhost/app"
    
    node --env-file=.env server.js                        # load one file
    node --env-file=.env --env-file=.env.local server.js  # later files override earlier ones
    node --env-file-if-exists=.env server.js              # no error if the file is missing
    What interviewers listen for
    • Read config from process.env; values are strings
    • Validate and parse config once at startup
    • --env-file and process.loadEnvFile() replace dotenv
    • Real environment variables win over file values
    • Never commit secrets; use a secret manager
  23. 23.What is CORS, and how do you handle it in a Node.js API?mid

    CORS (Cross-Origin Resource Sharing) is a browser mechanism. The same-origin policy stops a page on https://app.example.com from reading responses from https://api.example.com unless the API opts in with response headers, mainly Access-Control-Allow-Origin.

    For requests that aren't "simple", such as PUT or DELETE, a JSON Content-Type or custom headers like Authorization, the browser first sends a preflight OPTIONS request. The server must answer it with Access-Control-Allow-Methods and Access-Control-Allow-Headers, and can let the browser cache the answer with Access-Control-Max-Age.

    In Express, the cors middleware handles all of this. Best practices:

    • Allow an explicit list of origins, not *, for anything authenticated.
    • To send cookies you need Access-Control-Allow-Credentials: true, and then the origin can't be *.
    • CORS is not server-side protection: curl or another server ignores it completely. It only controls what browsers let pages read.
    What interviewers listen for
    • Browser-enforced opt-out of the same-origin policy
    • Server opts in with Access-Control-Allow-Origin
    • Non-simple requests trigger an OPTIONS preflight
    • Credentials require an explicit origin, not *
    • Not a server-side security control

    Likely follow-up: Why does a request work in Postman or curl but fail in the browser?

  24. 24.How do you create an HTTP server with the built-in node:http module, without Express?easy

    http.createServer() takes a request listener (req, res) that runs for every request. req is an IncomingMessage, a Readable stream with method, url and headers; the body isn't parsed for you, so you read the chunks yourself. res is a ServerResponse, a Writable stream: set the status and headers with writeHead(), or statusCode and setHeader(), then send data with write() and end().

    There's no routing, body parsing or error middleware; that's exactly what Express, Fastify and similar frameworks add on top. The raw API still matters, because frameworks wrap these same objects, and streaming a response or handling a raw upload means treating req and res as streams.

    Every request must finish with res.end(), or the client hangs. Also listen for 'error' on the server, for example EADDRINUSE when the port is taken. Use node:https with a key and certificate for TLS, and node:http2 for HTTP/2.

    import { createServer } from 'node:http';
    
    const server = createServer(async (req, res) => {
      if (req.method === 'POST' && req.url === '/echo') {
        const chunks = [];
        for await (const chunk of req) chunks.push(chunk); // req is a Readable
        res.writeHead(200, { 'content-type': 'application/json' });
        return res.end(Buffer.concat(chunks));
      }
      res.statusCode = 404;
      res.end('Not found');
    });
    
    server.listen(3000, () => console.log('listening on 3000'));
    What interviewers listen for
    • createServer((req, res) => ...) runs per request
    • req is a Readable; parse the body yourself
    • res is a Writable: writeHead, write, end
    • Always end the response
    • Frameworks add routing, parsing and middleware
  25. 25.How do you implement graceful shutdown in a Node.js server?mid

    Graceful shutdown means finishing in-flight work before exiting instead of dropping it. Kubernetes, Docker and PM2 send SIGTERM first and only force-kill with SIGKILL after a grace period (30 seconds by default in Kubernetes), so handle it:

    • Stop taking new work: server.close() stops listening, and its callback fires once existing connections have ended. Also fail your readiness check so the load balancer stops routing to you.
    • Let in-flight requests finish. Keep-alive sockets can hold close() open, so respond with Connection: close while draining or call server.closeIdleConnections().
    • Release resources: close database pools, flush logs, stop queue consumers.
    • Exit, with a hard timeout as a safety net. unref() stops that timer from keeping the process alive on its own.

    Handle SIGINT too, for Ctrl+C locally. In Docker, use the exec form, CMD ["node", "server.js"], so a wrapping shell doesn't swallow the signal.

    const server = app.listen(3000);
    
    process.on('SIGTERM', () => {
      console.log('SIGTERM received, draining');
      server.close(async () => {
        // runs once every open connection has ended
        await db.end();
        process.exit(0);
      });
      // safety net if a connection refuses to finish
      setTimeout(() => process.exit(1), 10_000).unref();
    });
    What interviewers listen for
    • Orchestrators send SIGTERM before SIGKILL
    • server.close() stops accepting, waits for connections
    • Close DB pools and flush logs before exiting
    • Force exit after a timeout
    • Deal with keep-alive sockets and readiness checks

    Likely follow-up: Why might server.close() never call its callback?

  26. 26.What steps would you take to secure a Node.js or Express API in production?mid

    I think in layers:

    • Validate all input at the boundary with a schema library like Zod or Ajv, cap body sizes, and reject unknown fields.
    • Prevent injection: parameterized queries, no string-built SQL, no user input in shell commands.
    • Security headers: helmet sets sensible defaults such as Content-Security-Policy, Strict-Transport-Security and X-Content-Type-Options: nosniff, and removes X-Powered-By.
    • Rate limiting on login and expensive endpoints, backed by Redis when you run several instances.
    • Auth done right: hash passwords with bcrypt, scrypt or Argon2, use HttpOnly, Secure, SameSite cookies, and check authorization on every resource, not just authentication.
    • Dependencies: commit the lockfile, run npm audit plus Dependabot or Renovate, and keep the dependency count low.
    • Operations: HTTPS everywhere, secrets from a secret manager, no stack traces in error responses, a non-root user, and a supported Node LTS line.
    What interviewers listen for
    • Schema-validate input and limit body size
    • Parameterized queries; no user input in shells
    • Security headers via helmet
    • Rate limit auth and expensive endpoints
    • Audit dependencies, hide error details, manage secrets

    Likely follow-up: How would you protect a login endpoint against brute-force attacks?

  27. 27.How do you scale a Node.js application?mid

    A Node process runs your JavaScript on one core, so scaling means running more processes and keeping them interchangeable.

    • On one machine: run one process per core with the cluster module or PM2's cluster mode (pm2 start app.js -i max), which also restarts crashed processes and supports zero-downtime reloads.
    • Across machines: put instances behind a load balancer such as Nginx, HAProxy or a cloud load balancer. In Kubernetes, the usual pattern is one Node process per container, scaling the number of replicas.
    • Keep the app stateless: sessions in Redis, uploads in object storage, nothing in memory that other instances need. WebSockets need sticky sessions plus a pub/sub layer like Redis so instances can reach each other's clients.
    • Take load off the app: caching, a CDN for static assets, queues for slow background jobs, database connection pooling and read replicas.

    Health checks and graceful shutdown make scaling up, scaling down and rolling deploys safe.

    What interviewers listen for
    • One JS thread per process, so run many processes
    • cluster or PM2 cluster mode on one machine
    • Load balancer across instances or containers
    • Stateless instances; shared state in Redis
    • Offload with caching, CDNs and queues
  28. 28.How would you find and fix a memory leak in a Node.js service?hard

    First confirm it's a leak: heapUsed from process.memoryUsage() or your metrics keeps climbing across garbage collections under steady load, until the process dies with JavaScript heap out of memory. Raising --max-old-space-size only buys time.

    Usual suspects:

    • Unbounded caches: a Map keyed by user, URL or request ID that's never evicted
    • Event listeners added per request and never removed
    • setInterval timers that are never cleared
    • Closures that keep large objects or whole requests alive
    • Global arrays used as queues or logs

    To find it, take heap snapshots: attach Chrome DevTools with --inspect, or write snapshots with v8.writeHeapSnapshot() or --heapsnapshot-signal. Take one, apply load, take another, and use the Comparison view to see which object types grew; the Retainers panel shows what keeps them alive.

    Fixes: bounded LRU caches with TTLs, removing listeners and clearing timers during cleanup, and WeakMap for per-object metadata.

    node --inspect server.js  # chrome://inspect > Memory > take heap snapshots
    
    node --heapsnapshot-signal=SIGUSR2 server.js
    kill -USR2 <pid>          # writes a Heap.*.heapsnapshot file to the cwd
    
    # write up to 2 snapshots automatically when nearing the heap limit
    node --heapsnapshot-near-heap-limit=2 --max-old-space-size=512 server.js
    What interviewers listen for
    • Heap grows across GCs under steady load
    • Culprits: unbounded caches, listeners, timers, closures
    • Compare heap snapshots before and after load
    • Retainers show what keeps objects alive
    • Bound caches, remove listeners, clear timers

    Likely follow-up: What is the difference between heapUsed, external and rss?

  29. 29.When would you cache data in process memory versus in Redis?mid

    In-process memory, a Map or an LRU cache library, is the fastest option: no network hop, no serialization. But each process has its own copy, so four cluster workers or ten pods mean ten caches that can disagree. It's also lost on every restart and deploy, and it lives on the V8 heap, adding GC pressure, so it must be size-bounded. It's good for small, hot, rarely changing data like config or feature flags.

    Redis is a shared cache: every instance sees the same data, it survives app restarts, and it offers TTLs, eviction policies and rich data structures. The cost is a network round trip, serialization and one more piece of infrastructure to run.

    Common patterns: cache-aside (read the cache; on a miss load from the database and store it with a TTL), invalidating or updating on writes, and preventing a stampede when a hot key expires by coalescing concurrent misses. Many systems use both: a tiny in-process LRU in front of Redis.

    What interviewers listen for
    • In-memory: fastest, but per-process and lost on restart
    • In-memory caches must be size-bounded
    • Redis: shared across instances, TTLs, eviction
    • Cache-aside with TTLs and invalidation on writes
    • Guard hot keys against cache stampedes
  30. 30.Can CommonJS code require() an ES module, and can an ES module import CommonJS? What are the gotchas?hard

    ESM importing CommonJS always works. The default import is module.exports. Named imports work only when Node's static analysis can detect them, which covers patterns like exports.add = ... but not every module.exports = ... assignment; otherwise you get a "named export not found" error, so import the default and destructure. createRequire(import.meta.url) gives you a real require inside ESM.

    CommonJS requiring ESM used to throw ERR_REQUIRE_ESM, forcing await import(). Since Node 22.12 (and 20.19), require() loads ES modules synchronously by default and returns the module namespace object, with the default export under .default. The limit: if the module graph uses top-level await, it throws ERR_REQUIRE_ASYNC_MODULE. Dynamic import() works from both formats.

    When publishing a package with both builds, watch for the dual package hazard: an app can load the CJS and ESM copies at the same time, giving duplicated state and failing instanceof checks.

    // main.cjs (Node 22.12+)
    const esm = require('./lib.mjs'); // works if lib.mjs has no top-level await
    console.log(esm.default(), esm.answer);
    
    // main.mjs
    import pkg from './legacy.cjs'; // default import = module.exports
    import { add } from './legacy.cjs'; // only if statically detectable
    import { createRequire } from 'node:module';
    const require = createRequire(import.meta.url);
    What interviewers listen for
    • ESM can import CJS; default is module.exports
    • CJS named imports need statically detectable exports
    • require(esm) works by default since Node 22.12
    • Top-level await throws ERR_REQUIRE_ASYNC_MODULE
    • Dual package hazard: two copies, duplicated state
  31. 31.What is the process object, and how do you read command-line arguments?easy

    process is a global object describing the running Node process:

    • process.argv: command-line arguments. Index 0 is the Node executable and index 1 the script path, so user arguments start at process.argv.slice(2). For flags, the built-in util.parseArgs handles types, defaults and short aliases.
    • process.env: environment variables, always strings.
    • process.exit(code) ends immediately; setting process.exitCode lets pending work finish first. Non-zero means failure.
    • process.cwd() is where the command was run from, not where the file lives.
    • process.stdin, process.stdout and process.stderr are streams.
    • Diagnostics: process.pid, process.platform, process.memoryUsage(), process.uptime(), process.hrtime.bigint().

    process is also an EventEmitter: process.on('SIGTERM', ...) handles signals, and exit, uncaughtException and unhandledRejection are events on it too.

    // node cli.mjs --name Ada -v build
    import { parseArgs } from 'node:util';
    
    console.log(process.argv.slice(2)); // [ '--name', 'Ada', '-v', 'build' ]
    const { values, positionals } = parseArgs({
      allowPositionals: true,
      options: {
        name: { type: 'string', default: 'world' },
        verbose: { type: 'boolean', short: 'v' },
      },
    });
    console.log(values.name, values.verbose, positionals); // Ada true [ 'build' ]
    What interviewers listen for
    • User arguments are process.argv.slice(2)
    • util.parseArgs parses flags without dependencies
    • process.exitCode is gentler than process.exit()
    • process.cwd() is not the script directory
    • Signals and lifecycle events via process.on
  32. 32.__dirname is not defined in ES modules. How do you get the current file's path, and how do path.join and path.resolve differ?easy

    In ESM, __dirname, __filename and require don't exist. Each module has import.meta.url instead, a file: URL. Since Node 20.11 you also get import.meta.dirname and import.meta.filename, plain paths equivalent to the old globals, and they're stable as of Node 22.16. On older versions, derive them with fileURLToPath(import.meta.url) and path.dirname(). Another option is new URL('./data.json', import.meta.url), which fs functions accept directly.

    Don't confuse these with process.cwd(): that's where the command was run from, so a relative path like readFile('data.json') breaks when someone starts the app from another directory. Build paths from the module's own directory.

    As for path: path.join() concatenates segments with the platform separator and normalizes ... path.resolve() processes segments right to left until it has an absolute path, falling back to the current working directory.

    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    console.log(import.meta.dirname); // /app/src
    console.log(import.meta.filename); // /app/src/server.js
    
    // Equivalent that also works on older Node versions
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = path.dirname(__filename);
    
    path.join('a', '../b', 'c.txt'); // 'b/c.txt'
    path.resolve('data', 'c.txt'); // '<current working dir>/data/c.txt'
    What interviewers listen for
    • ESM has no __dirname, __filename or require
    • Use import.meta.dirname and import.meta.filename
    • Fallback: fileURLToPath(import.meta.url)
    • process.cwd() is not the module directory
    • path.resolve returns absolute; path.join concatenates
  33. 33.How does require() resolve and cache modules? What happens with circular dependencies?mid

    Resolution: require(x) first checks built-in modules like fs or node:path. A path starting with ./, ../ or / is tried as an exact file, then with .js, .json and .node appended, then as a directory using its package.json main or index.js. A bare name like express is looked up in node_modules in the current directory, then in each parent directory up to the root; the package's exports field, if present, decides which file you get.

    Caching: the first require runs the module and stores it in require.cache, keyed by resolved filename. Later calls return the same module.exports without re-running the code, so a module is effectively a singleton, handy for a shared database pool.

    Circular dependencies don't crash: when b requires a while a is still loading, b receives a's partially filled exports, as the snippet shows. Avoid cycles, or access imports lazily inside functions.

    // a.js
    exports.loaded = false;
    const b = require('./b');
    console.log('in a, b.done =', b.done);
    exports.loaded = true;
    
    // b.js
    const a = require('./a');
    console.log('in b, a.loaded =', a.loaded); // false: a.js is only half-run
    exports.done = true;
    
    // node a.js prints "in b, a.loaded = false", then "in a, b.done = true"
    What interviewers listen for
    • Core modules, then relative paths, then node_modules
    • Bare names walk up parent node_modules folders
    • Modules run once and are cached: singletons
    • require.cache is keyed by resolved filename
    • Cycles expose partially initialized exports

    Likely follow-up: How would you force a module to be re-executed in tests?

  34. 34.How should you store user passwords in a Node.js application?mid

    Never store plain text, and never use a fast hash like MD5 or SHA-256 on its own: GPUs can try billions of guesses per second. Use a slow, salted password hashing function:

    • Argon2id: OWASP's first choice, via the argon2 package
    • scrypt: built into node:crypto, as in the snippet
    • bcrypt: battle-tested, via bcrypt or bcryptjs; it only uses the first 72 bytes of a password

    A unique random salt per password means identical passwords get different hashes and precomputed tables are useless. The cost factor keeps each guess expensive; raise it as hardware improves and rehash on the user's next login.

    Details that matter: use the async APIs so hashing runs on the thread pool instead of blocking the event loop, compare with crypto.timingSafeEqual (the libraries do this for you), and rate-limit login attempts. Store the algorithm and parameters with the hash so you can migrate later.

    import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
    import { promisify } from 'node:util';
    
    const scryptAsync = promisify(scrypt);
    
    async function hashPassword(password) {
      const salt = randomBytes(16);
      const hash = await scryptAsync(password, salt, 64);
      return `${salt.toString('hex')}:${hash.toString('hex')}`;
    }
    async function verifyPassword(password, stored) {
      const [salt, hash] = stored.split(':').map((h) => Buffer.from(h, 'hex'));
      return timingSafeEqual(hash, await scryptAsync(password, salt, 64));
    }
    What interviewers listen for
    • Never plain text or fast hashes like SHA-256
    • Use Argon2id, scrypt or bcrypt
    • Unique random salt per password
    • Tunable cost factor; rehash when raising it
    • Async hashing and constant-time comparison
  35. 35.What injection attacks should a Node.js developer guard against, and how do you prevent them?mid

    Injection happens when untrusted input gets interpreted as code or query syntax.

    • SQL injection: never concatenate input into SQL. Use parameterized queries, or an ORM or query builder that parameterizes for you.
    • NoSQL injection: a JSON body can contain operators like { "$gt": "" } where you expected a string. Validate types, or strip keys starting with $.
    • Command injection: exec runs through a shell, so ; or && in input runs extra commands. Use execFile or spawn with an argument array.
    • Path traversal: a filename like ../../etc/passwd. Resolve the path and check it stays inside the allowed directory.
    • Prototype pollution: deep-merging user JSON with keys like __proto__. Avoid naive merges; use Map or Object.create(null) for user-keyed data.

    The general defense is validation at the boundary: a schema (Zod, Ajv, Joi) that checks types, formats, lengths and allowed fields before data reaches your logic, plus a least-privilege database user.

    // SQL: string-built queries are injectable
    db.query(`SELECT * FROM users WHERE email = '${email}'`); // vulnerable
    db.query('SELECT * FROM users WHERE email = $1', [email]); // parameterized
    
    // NoSQL: a body of { "username": { "$gt": "" } } matches the first user
    const user = await users.findOne({ username: req.body.username });
    
    // Command: exec runs a shell, so file = "x.jpg; rm -rf ~" runs rm
    exec(`convert ${file} out.png`); // vulnerable
    execFile('convert', [file, 'out.png']); // arguments, no shell
    What interviewers listen for
    • Parameterized queries, never string-built SQL
    • Validate types to block NoSQL operator injection
    • execFile or spawn with arguments, not exec
    • Guard against path traversal and prototype pollution
    • Schema validation at every entry point
  36. 36.What are the differences between REST and GraphQL, and when would you pick each?easy

    REST models resources as URLs, like /users/42/orders, and uses HTTP semantics: verbs such as GET, POST and DELETE, status codes and caching headers. It's simple and works with HTTP caches and CDNs out of the box. The downsides are over-fetching (endpoints return fixed shapes) and under-fetching (a screen may need several round trips).

    GraphQL exposes a single endpoint and a typed schema. The client sends a query naming exactly the fields it needs, including nested data, and gets it in one round trip; the schema doubles as documentation. The costs:

    • Resolvers can trigger N+1 database queries; batch them with DataLoader.
    • HTTP caching is harder, since queries are usually POST requests to one URL.
    • Errors often come back with a 200 status inside an errors array.
    • You need depth or complexity limits so a client can't send a very expensive query.

    I'd pick REST for public APIs, simple CRUD and cache-heavy reads, and GraphQL when many clients, like web and mobile, need different shapes of the same data.

    What interviewers listen for
    • REST: resource URLs, HTTP verbs, status codes
    • REST works naturally with HTTP caching
    • GraphQL: one endpoint, typed schema, client picks fields
    • GraphQL pitfalls: N+1 queries, caching, costly queries
    • Choose by client variety and caching needs
  37. 38.How would you implement rate limiting for an API that runs on several Node.js instances?mid

    Rate limiting caps how many requests a client can make in a time window, to stop brute-force logins, scraping and accidental overload. Decide on the key (IP address, API key or user ID) and the algorithm:

    • Fixed window: a counter per minute. Simple, but it allows a double burst around window edges.
    • Sliding window: smooths the edges by weighting the previous window or keeping timestamps.
    • Token bucket: tokens refill at a steady rate and each request spends one, allowing controlled bursts.

    With several instances, an in-memory counter is wrong, because each instance would allow the full quota. Keep counters in Redis with an atomic INCR and an expiry, as in the snippet. Reject with 429 Too Many Requests and a Retry-After header.

    In practice, express-rate-limit with a Redis store, or limiting at the API gateway or reverse proxy, is common. Behind a proxy, configure Express's trust proxy setting so req.ip is the client's address, not the load balancer's.

    // Fixed window: at most 100 requests per IP per minute, across all instances
    async function rateLimit(req, res, next) {
      const windowId = Math.floor(Date.now() / 60_000); // current minute
      const key = `rl:${req.ip}:${windowId}`;
      const count = await redis.incr(key); // atomic, shared by every instance
      if (count === 1) await redis.expire(key, 60);
      if (count > 100) return res.status(429).json({ error: 'Too many requests' });
      next();
    }
    What interviewers listen for
    • Choose a key: IP, API key or user
    • Fixed window, sliding window or token bucket
    • Shared Redis counters across instances
    • Respond 429 with Retry-After
    • Set trust proxy behind a load balancer
  38. 39.How do you approach logging and observability in a Node.js service?mid

    I think in three signals:

    • Logs: structured JSON rather than free text, using a fast logger like pino, with levels (debug, info, warn, error) configured per environment. Every line carries a request or correlation ID so you can follow one request across services; AsyncLocalStorage lets you attach it without passing it through every function. Never log passwords, tokens or personal data.
    • Metrics: request rate, error rate and latency percentiles, plus Node-specific ones like event loop delay, heap usage and GC pauses, exported to Prometheus or a similar system and alerted on.
    • Traces: OpenTelemetry instruments HTTP, database and queue calls, so you can see where a slow request spent its time across services.

    Operationally, write logs to stdout and let the platform collect them instead of managing log files in the app. Add health and readiness endpoints, and make sure crashes log the error before the process exits.

    What interviewers listen for
    • Structured JSON logs with levels, e.g. pino
    • Correlation IDs, via AsyncLocalStorage
    • Never log secrets or personal data
    • Metrics: latency, errors, event loop delay, heap
    • Distributed tracing with OpenTelemetry
  39. 40.An endpoint is slow and CPU usage is high. How would you profile a Node.js application?hard

    First measure instead of guessing: reproduce with a load tool like autocannon and watch latency percentiles, CPU and event loop delay. High CPU plus growing event loop delay points at JavaScript hogging the main thread; low CPU with slow responses points at I/O, like a slow query or an exhausted connection pool or thread pool.

    For CPU problems, record a CPU profile:

    • --inspect lets Chrome DevTools or VS Code attach; record a profile while the load runs.
    • --cpu-prof writes a .cpuprofile on exit that DevTools can open.
    • --prof plus node --prof-process gives a text summary of V8's tick log.

    Then read the flame graph: the widest bars are where the time goes. Typical culprits are big JSON work, regexes, synchronous crypto, sorting large arrays, or a *Sync call. Community tools like Clinic.js and 0x wrap this in friendlier flame graphs and diagnostics.

    In production, prefer sampling or an APM, and never expose the inspector port publicly.

    # 1. Reproduce under load
    npx autocannon -c 50 -d 30 http://localhost:3000/report
    
    # 2a. Attach Chrome DevTools (chrome://inspect) and record a CPU profile
    node --inspect server.js
    
    # 2b. Or write a .cpuprofile file when the process exits
    node --cpu-prof server.js
    
    # 2c. Or use V8's sampling profiler and summarize its log
    node --prof server.js
    node --prof-process isolate-*.log > profile.txt
    What interviewers listen for
    • Reproduce under load and measure first
    • High CPU plus event loop delay means JS-bound
    • CPU profile via --inspect, --cpu-prof or --prof
    • Wide flame graph bars show the hot code
    • Never expose the inspector publicly
  40. 41.How is JavaScript in Node.js different from JavaScript in the browser?easy

    The language is the same (Node uses V8, Chrome's engine), but the host environment differs:

    • APIs: browsers provide the DOM, window, document and localStorage. Node has none of those, but gives you the file system, networking, child processes, process and Buffer.
    • Globals: the global object is window in browsers and global in Node; globalThis works in both.
    • Modules: browsers load ES modules by URL. Node supports both CommonJS and ESM and resolves bare specifiers like express from node_modules.
    • Security: browser code is sandboxed by the same-origin policy. Node code runs with the full permissions of its user, unless you opt into the permission model with --permission.
    • Event loop: Node's libuv loop adds process.nextTick and setImmediate; browsers interleave rendering and requestAnimationFrame.
    • Versions: you choose the Node version, while browser code must support many engines.

    Many web APIs are shared now: fetch, URL, AbortController, TextEncoder, Web Streams, structuredClone and crypto.subtle.

    What interviewers listen for
    • Same language and engine, different host APIs
    • No DOM in Node; file system and network instead
    • globalThis works in both environments
    • Node code has full OS access; browsers sandbox
    • Many web APIs are shared: fetch, URL, streams
  41. 42.Node.js has a built-in fetch. How does it differ from fetch in the browser, and what should you watch out for?easy

    fetch has been a global since Node 18 and stable since Node 21. It's implemented by undici, Node's own HTTP client, and follows the same web standard, so Request, Response, Headers, FormData and AbortController work as they do in the browser.

    Differences and gotchas:

    • No CORS and no browser sandbox: server code can call any origin. There's also no cookie jar, so you set cookies and auth headers yourself.
    • It only rejects on network errors. A 404 or 500 resolves, so always check res.ok or res.status.
    • Set timeouts explicitly with AbortSignal.timeout(ms), which makes fetch reject with a TimeoutError.
    • res.body is a web ReadableStream; convert it with Readable.fromWeb() when you need a Node stream, for example to pipe into a file.
    • Consume or cancel every response body so the connection can be reused.

    For heavy use, undici's own API gives finer control over connection pools.

    const res = await fetch('https://api.example.com/users/42', {
      headers: { authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(5000), // fail after 5 s
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404 and 500 do not reject
    const user = await res.json();
    What interviewers listen for
    • Global since Node 18, stable since 21, built on undici
    • HTTP errors resolve; check res.ok
    • Add timeouts with AbortSignal.timeout()
    • No CORS enforcement and no cookie jar
    • The body is a web ReadableStream
  42. 43.What is for await...of, and how would you use it to process a large file line by line?mid

    for await...of loops over an async iterable, an object whose [Symbol.asyncIterator]() method hands out promises of values, awaiting each one before running the loop body.

    Plenty of things in Node are async iterable:

    • Readable streams, including fs streams and incoming HTTP requests
    • readline interfaces, which yield one line at a time, so the snippet handles a multi-gigabyte log with constant memory
    • events.on(emitter, 'name'), which turns events into an async sequence
    • setInterval from node:timers/promises

    It gives you backpressure for free: the stream only reads more when the loop asks for the next item, so slow processing slows reading down instead of buffering. Stream errors are thrown inside the loop, so a normal try/catch works, and break destroys the stream.

    The trade-off is that iterations run sequentially. For independent work, like many HTTP calls, use promises with a concurrency limit instead. You can build your own async iterables with async function* generators.

    import { createReadStream } from 'node:fs';
    import { createInterface } from 'node:readline';
    
    const rl = createInterface({ input: createReadStream('app.log'), crlfDelay: Infinity });
    
    let errors = 0;
    for await (const line of rl) {
      if (line.startsWith('ERROR')) errors++;
    }
    console.log('errors:', errors);
    What interviewers listen for
    • Iterates async iterables, awaiting each value
    • Streams, readline and events.on are async iterable
    • Pull-based, so backpressure is automatic
    • Errors throw inside the loop; break destroys the stream
    • Sequential by design
  43. 44.How do you cancel an async operation in Node.js, such as a slow HTTP request or a timer?mid

    Node uses the web-standard AbortController: create a controller, pass its signal to the operation, and call controller.abort() to cancel. Many core APIs accept a signal:

    • fetch and http.request
    • fs.readFile and the fs/promises functions
    • timers/promises such as setTimeout
    • events.once, stream.pipeline and child_process.spawn

    Helpers make signals composable: AbortSignal.timeout(ms) aborts after a delay, and AbortSignal.any([...]) aborts as soon as any of several signals does, as in the snippet, which combines a manual cancel with a timeout.

    A cancelled operation rejects, usually with an AbortError (fetch reports a TimeoutError for AbortSignal.timeout), so check err.name and don't treat it as a real failure. In your own async functions, accept a signal option and call signal.throwIfAborted() at checkpoints or listen for its abort event. A common production use is stopping downstream calls when the client disconnects.

    import { setTimeout as sleep } from 'node:timers/promises';
    
    const controller = new AbortController();
    res.on('close', () => controller.abort()); // e.g. the client went away
    
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]);
    try {
      const upstream = await fetch(url, { signal });
      await sleep(100, null, { signal });
    } catch (err) {
      if (err.name === 'AbortError' || err.name === 'TimeoutError') console.log('cancelled');
      else throw err;
    }
    What interviewers listen for
    • Pass controller.signal; call abort() to cancel
    • fetch, fs, timers, streams and spawn accept signals
    • AbortSignal.timeout() and AbortSignal.any() compose
    • Aborted operations reject with AbortError
    • Accept a signal in your own async APIs
  44. 45.How do timers work in Node.js, and what are unref(), refresh() and timers/promises for?mid

    setTimeout(fn, ms) schedules fn for the timers phase no earlier than ms from now; if the loop is busy, it runs late. A delay of 0 becomes 1 ms, and delays above 2147483647 ms (about 24.8 days) overflow to 1 ms with a warning. setInterval repeats, but if a callback runs long the next one is simply late; for work that must not overlap, chain setTimeout calls instead.

    Unlike browsers, Node returns a Timeout object, not a number:

    • unref(): the timer no longer keeps the process alive, useful for background metrics or cleanup intervals.
    • ref() undoes that, and hasRef() checks it.
    • refresh(): restarts the countdown with the same callback, handy for idle timeouts.

    node:timers/promises offers promise versions: await setTimeout(1000) as a sleep, setImmediate(), and setInterval() as an async iterator for for await loops, all cancellable with an AbortSignal. Clear timers you no longer need: forgotten intervals are a classic leak.

    What interviewers listen for
    • The delay is a minimum, not a guarantee
    • Node timers return Timeout objects, not numbers
    • unref() stops a timer keeping the process alive
    • refresh() restarts an existing timer
    • timers/promises gives awaitable, abortable timers
  45. 46.How do WebSockets work, and how would you add real-time features to a Node.js app?mid

    A WebSocket starts as an HTTP request with an Upgrade: websocket header. After the 101 Switching Protocols response, the same TCP connection becomes a persistent, full-duplex channel where either side can send messages at any time. That beats polling for chat, live dashboards, notifications and multiplayer features.

    In Node, the client is built in: Node 22 ships a browser-compatible global WebSocket. There's no built-in server, so you use the ws package (low-level and fast) or Socket.IO, which adds rooms, reconnection and fallbacks on top of its own protocol.

    Production concerns:

    • Authenticate during the upgrade, for example with the session cookie or a token.
    • Heartbeats: ping/pong frames detect dead connections.
    • Scaling: each connection lives on one instance, so use sticky sessions and a pub/sub layer like Redis to broadcast across instances.
    • Backpressure: check bufferedAmount before flooding slow clients.

    If data only flows from server to client, Server-Sent Events are simpler.

    What interviewers listen for
    • HTTP Upgrade, then a persistent full-duplex connection
    • Node 22 has a global WebSocket client
    • Servers need ws or Socket.IO
    • Authenticate on upgrade; heartbeat with ping/pong
    • Scale with sticky sessions plus Redis pub/sub
  46. 47.How do you test Node.js code, and what does the built-in node:test runner offer?mid

    Node ships a test runner, node:test, stable since Node 20, so many projects don't need Jest or Vitest:

    • test(), or describe() and it(), with before, after, beforeEach and afterEach hooks
    • Assertions from node:assert/strict
    • Mocking with mock.fn(), mock.method() and mock.timers
    • node --test finds files like *.test.js or anything in a test directory and runs each file in its own process; --watch reruns on change
    • .only, skip, todo, --test-name-pattern, reporters like spec, tap and junit, and coverage with --experimental-test-coverage

    Strategy matters more than the runner: unit tests for pure logic, integration tests that start the app and hit it over HTTP (with supertest, or plain fetch against a random port), and a real database in a container rather than mocking everything. Inject dependencies, like the API client in the snippet, so they're easy to replace.

    import { test, describe, mock } from 'node:test';
    import assert from 'node:assert/strict';
    import { sum, getUser } from './user.js';
    
    describe('sum', () => {
      test('adds numbers', () => assert.equal(sum(2, 3), 5));
    });
    
    test('getUser uppercases the name', async () => {
      const api = { fetchUser: mock.fn(async () => ({ name: 'ada' })) };
      assert.equal(await getUser(api, 1), 'ADA');
      assert.equal(api.fetchUser.mock.callCount(), 1);
    });
    // Run with: node --test
    What interviewers listen for
    • node:test is built in and stable
    • describe/it, hooks and node:assert/strict
    • Built-in mocks: mock.fn, mock.method, mock.timers
    • node --test, --watch and a coverage flag
    • Mix unit tests with HTTP-level integration tests
  47. 48.How would you handle large file uploads in a Node.js API without running out of memory?hard

    The key is to stream, never buffer: reading a 2 GB upload into memory per request will kill the process under load. Pipe the request straight to its destination with pipeline(), which applies backpressure, so a slow disk or network slows the client down instead of filling RAM.

    For multipart/form-data, the browser's form format, use a streaming parser like busboy, or multer with disk storage rather than memory storage. For raw binary bodies, as in the snippet, the request itself is the stream.

    Other essentials:

    • Limits on file size, file count and field size; fail fast with 413.
    • Don't trust metadata: generate your own filenames, which prevents path traversal, and check the real type from the file's magic bytes, not the extension or Content-Type.
    • Clean up partial files when a pipeline fails.
    • Store files in object storage like S3, and consider pre-signed URLs so clients upload directly and your server never handles the bytes.
    const MAX = 10 * 1024 * 1024; // 10 MB
    
    app.put('/upload', async (req, res) => {
      let size = 0;
      const limit = new Transform({
        transform(chunk, _enc, cb) {
          size += chunk.length;
          cb(size > MAX ? new Error('File too large') : null, chunk);
        },
      });
      const path = join(UPLOAD_DIR, randomUUID()); // never trust the client's filename
      await pipeline(req, limit, createWriteStream(path)); // streams with backpressure
      res.status(201).json({ id: basename(path) });
    });
    What interviewers listen for
    • Stream uploads; never buffer whole files
    • pipeline() gives backpressure and cleanup
    • Enforce size and count limits
    • Generate filenames; verify type by content
    • Object storage and pre-signed URLs
  48. 49.When does the timeout below fire, and why is recursive process.nextTick dangerous where recursive setImmediate is not?hard

    The nextTick queue is drained completely before the event loop can move on, including callbacks added while it's draining. A function that keeps rescheduling itself with process.nextTick therefore never lets the loop reach the timers or poll phase: the timeout only fires after all million iterations. With unbounded recursion, timers, I/O and incoming requests would starve forever, even though nothing looks like a busy loop. Recursive promise callbacks behave the same way, because the microtask queue is also drained completely.

    setImmediate callbacks run in the check phase, and any scheduled during that phase wait for the next iteration. The loop runs timers and I/O in between, so the timeout fires almost immediately.

    Rule of thumb: use process.nextTick sparingly, for things like emitting an event after a constructor returns or making a callback API consistently asynchronous. To yield to the loop while chunking long work, use setImmediate.

    let n = 0;
    setTimeout(() => console.log('timeout fired after', n, 'iterations'), 0);
    
    function tick() {
      if (++n < 1_000_000) process.nextTick(tick);
    }
    tick();
    // timeout fired after 1000000 iterations
    // With setImmediate(tick) instead, it fires after only a handful
    What interviewers listen for
    • The nextTick queue drains fully before the loop continues
    • Recursive nextTick starves timers and I/O
    • Recursive microtasks starve the loop the same way
    • Immediates added during check wait an iteration
    • Chunk long work with setImmediate
  49. 50.You need to call an API for 10,000 items, but at most 5 requests may be in flight at once. How would you implement that?hard

    Promise.all(items.map(fn)) starts all 10,000 requests at once: you hit rate limits, exhaust sockets or memory, and overload the other service. A sequential for...of with await is safe but slow. The answer is a concurrency pool.

    The snippet starts limit workers. Each worker loops, claims the next index and awaits the task; as soon as a task finishes, that worker takes the next item, so up to limit requests are always in flight. Claiming next++ needs no lock, because only one worker runs at a time between awaits. Results are stored by index to preserve input order.

    Worth discussing:

    • Errors: here the first rejection rejects the whole call while other workers keep going. For partial failures, catch per item and return { status, value } objects like Promise.allSettled.
    • Retries with exponential backoff for transient failures.
    • Cancellation through an AbortSignal.

    In real projects, p-limit or p-map implement exactly this.

    async function mapLimit(items, limit, fn) {
      const results = new Array(items.length);
      let next = 0;
      async function worker() {
        while (next < items.length) {
          const i = next++; // no race: only one worker runs at a time between awaits
          results[i] = await fn(items[i], i);
        }
      }
      await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
      return results;
    }
    // const pages = await mapLimit(urls, 5, (url) => fetch(url).then((r) => r.text()));
    What interviewers listen for
    • Promise.all on everything floods the downstream service
    • N workers pull from a shared index
    • No lock needed: single-threaded between awaits
    • Store results by index to keep order
    • Decide error, retry and cancellation behavior
  50. 51.What is AsyncLocalStorage, and how would you use it to attach a request ID to every log line?hard

    AsyncLocalStorage from node:async_hooks is storage that follows an asynchronous call chain, similar to thread-local storage in other languages. als.run(store, fn) runs fn with that store, and anything called from it, including code after an await, in setTimeout callbacks or in promise chains, can read it with als.getStore(). Concurrent requests each see their own store even though they interleave on one thread.

    The classic use is request context: a middleware creates a store with a request ID (reusing an incoming x-request-id header if there is one), and the logger reads it, so every log line from that request is correlated without threading an ID through every function. The same mechanism powers tracing libraries like OpenTelemetry, per-request database transactions and tenant IDs.

    Caveats: getStore() returns undefined outside run(), so handle that. Context can get lost in libraries that queue callbacks themselves, such as some connection pools; AsyncResource fixes that. And don't use it to hide ordinary function arguments.

    import { AsyncLocalStorage } from 'node:async_hooks';
    import { randomUUID } from 'node:crypto';
    
    const requestContext = new AsyncLocalStorage();
    
    export function log(msg) {
      const requestId = requestContext.getStore()?.requestId;
      console.log(JSON.stringify({ requestId, msg }));
    }
    
    app.use((req, res, next) => {
      requestContext.run({ requestId: req.get('x-request-id') ?? randomUUID() }, next);
    });
    // Anywhere downstream, even after awaits, log('charging card') includes the ID
    What interviewers listen for
    • Context that follows an async call chain
    • run(store, fn) sets it; getStore() reads it
    • Each concurrent request sees its own store
    • Used for request IDs, tracing and transactions
    • Returns undefined outside run()
  51. 52.What does util.promisify do, and how would you implement it yourself?mid

    util.promisify(fn) converts a function that follows the error-first callback convention, with the callback as its last argument, into one that returns a promise. That lets older APIs work with async/await.

    The implementation returns a wrapper that creates a promise, calls the original function with the caller's arguments plus its own callback, and rejects if err is set, otherwise resolves with the value. Using fn.call(this, ...) preserves this, so promisified methods still work.

    Details worth mentioning:

    • The real util.promisify honors a util.promisify.custom symbol for functions whose callbacks don't fit the pattern. dns.lookup, for instance, calls back with two values, and its promisified version resolves with { address, family }.
    • util.callbackify goes the other way.
    • Many core modules already have promise versions: fs/promises, timers/promises, dns/promises and stream/promises. Prefer those to promisifying yourself.
    function promisify(fn) {
      return function (...args) {
        return new Promise((resolve, reject) => {
          fn.call(this, ...args, (err, value) => (err ? reject(err) : resolve(value)));
        });
      };
    }
    
    const readFile = promisify(fs.readFile);
    const text = await readFile('notes.txt', 'utf8');
    What interviewers listen for
    • Converts error-first callback APIs to promises
    • The wrapper appends its own callback
    • Reject on err, resolve with the value
    • Preserve this with fn.call
    • util.promisify.custom handles unusual signatures
  52. 53.How do Deno and Bun compare with Node.js?easy

    All three run JavaScript and TypeScript on the server; they differ in priorities.

    • Node.js: V8 plus libuv, the most mature option, with the largest ecosystem, long-term support releases and the widest hosting and tooling support. It keeps adding features its competitors popularized: a test runner, watch mode, .env loading, a permission model and TypeScript type stripping.
    • Deno: created by Ryan Dahl, Node's original author, and also built on V8. It's secure by default: scripts need explicit flags like --allow-net or --allow-read. It runs TypeScript natively, favors web-standard APIs, and bundles a formatter, linter and test runner. Deno 2 added strong npm and package.json compatibility.
    • Bun: built on JavaScriptCore, Safari's engine, and written in Zig. It's an all-in-one toolkit (runtime, package manager, bundler and test runner) focused on speed, especially startup and install times, and aims to be a drop-in replacement for Node APIs.

    In practice Node is the safe default for production. Deno and Bun are worth considering where their tooling or security model helps, after checking that your dependencies work.

    What interviewers listen for
    • Node: V8, largest ecosystem, LTS releases
    • Deno: secure by default with permission flags
    • Deno: native TypeScript, web APIs, built-in tools
    • Bun: JavaScriptCore, all-in-one, speed-focused
    • Check dependency compatibility before switching
  53. 54.How does the cluster module let several processes listen on the same port, and what are its limitations?hard

    The primary process forks workers with cluster.fork(), which uses child_process.fork() under the hood, so each worker is a full Node process with its own memory and event loop, connected to the primary over IPC. When a worker calls listen(3000), it doesn't bind the port itself: the primary owns the listening socket.

    By default on every platform except Windows, the primary uses round-robin scheduling: it accepts each connection and hands the socket to the next worker. The alternative, SCHED_NONE, lets workers accept from the shared socket directly, which in practice spreads load unevenly.

    Limitations:

    • No shared state: in-memory caches, sessions and rate-limit counters are per worker, so move them to Redis or a database.
    • WebSockets and Socket.IO long polling need sticky sessions.
    • You restart dead workers yourself, as the snippet does on exit.
    • In containers, it's usually simpler to run one process per container and let the orchestrator scale replicas. PM2 wraps cluster with restarts and zero-downtime reloads.
    import cluster from 'node:cluster';
    import http from 'node:http';
    import { availableParallelism } from 'node:os';
    
    if (cluster.isPrimary) {
      for (let i = 0; i < availableParallelism(); i++) cluster.fork();
      cluster.on('exit', (worker, code) => {
        console.log(`worker ${worker.process.pid} died (${code}), restarting`);
        cluster.fork();
      });
    } else {
      http.createServer((req, res) => res.end(`pid ${process.pid}\n`)).listen(3000);
    }
    What interviewers listen for
    • Primary forks workers; each is a full process
    • The primary owns the port and hands out connections
    • Round-robin by default, except on Windows
    • No shared memory: externalize state
    • Restart workers on exit; consider one process per container
  54. 55.How would you implement a custom Transform stream, and when do you need flush?hard

    A Transform is a duplex stream whose output is computed from its input. You implement two hooks, either as constructor options or by subclassing and defining _transform and _flush:

    • transform(chunk, encoding, callback) runs for every incoming chunk. Emit output with callback(null, data), or call this.push() any number of times and then callback(). Passing an error to callback fails the stream. Calling callback is what signals you're ready for the next chunk, which is how backpressure propagates.
    • flush(callback) runs once after the last chunk, before the stream ends. You need it whenever you buffer or aggregate: emitting a digest as in the snippet (used as pipeline(source, checksum(console.log), destination)), the last partial line of a line splitter, or the closing bracket of a JSON array.

    Set objectMode (or readableObjectMode) when you emit JavaScript objects instead of bytes, for example a CSV parser yielding rows.

    For simple cases, pipeline() also accepts an async generator as a step, like async function* (source) { for await (const chunk of source) yield transform(chunk); }, which is often shorter and easier to read.

    import { Transform } from 'node:stream';
    import { createHash } from 'node:crypto';
    function checksum(onDone) {
      const hash = createHash('sha256');
      return new Transform({
        transform(chunk, _enc, cb) {
          hash.update(chunk);
          cb(null, chunk); // pass data through unchanged
        },
        // flush runs once, after the last chunk
        flush(cb) { onDone(hash.digest('hex')); cb(); },
      });
    }
    What interviewers listen for
    • transform(chunk, enc, callback) handles each chunk
    • Output via callback(null, data) or this.push()
    • Calling callback propagates backpressure
    • flush emits buffered or aggregate data at the end
    • Async generators work as pipeline steps
  55. 56.How do worker threads exchange data, and when would you use SharedArrayBuffer and Atomics?hard

    There are three levels:

    • Copying: postMessage(value) and workerData use the structured clone algorithm, so the receiver gets a deep copy. Plain objects, arrays, Map, Date and typed arrays work; functions throw a DataCloneError, and class instances arrive as plain objects. Copying large data costs time and memory.
    • Transferring: postMessage(buf, [buf]) moves an ArrayBuffer to the other thread without copying. The sender's buffer becomes detached, with byteLength 0, so only one side owns it at a time.
    • Sharing: a SharedArrayBuffer is visible to all threads at once. Because workers truly run in parallel, a plain x++ on shared memory can lose updates; Atomics.add, Atomics.compareExchange, Atomics.wait and Atomics.notify provide atomic operations and signaling, as in the snippet.

    Use sharing only for hot numeric data like counters, ring buffers or pixel data; for most tasks, message passing is simpler and safer. Also reuse workers through a pool, for example with piscina, since each worker costs a new V8 isolate to start.

    import { Worker } from 'node:worker_threads';
    
    const shared = new Int32Array(new SharedArrayBuffer(4));
    const code = `
      const { workerData } = require('node:worker_threads');
      for (let i = 0; i < 100000; i++) Atomics.add(workerData, 0, 1);
    `;
    const workers = [1, 2, 3, 4].map(() => new Worker(code, { eval: true, workerData: shared }));
    await Promise.all(workers.map((w) => new Promise((r) => w.on('exit', r))));
    console.log(shared[0]); // 400000: every increment counted
    What interviewers listen for
    • postMessage and workerData copy via structured clone
    • Transfer lists move ArrayBuffers without copying
    • SharedArrayBuffer is visible to every thread
    • Use Atomics to avoid lost updates
    • Pool workers, because startup is expensive
  56. 57.Behind a load balancer, your Node.js API returns occasional 502 errors. How can HTTP keep-alive settings cause that?hard

    HTTP/1.1 keep-alive reuses one TCP connection for many requests, avoiding a new TCP and TLS handshake each time. Load balancers keep a pool of idle connections to your servers and reuse them.

    The trap is mismatched idle timeouts. Node's server.keepAliveTimeout defaults to 5 seconds: after 5 idle seconds, Node closes the connection. Many load balancers keep idle connections much longer; an AWS Application Load Balancer, for example, defaults to 60 seconds. If the balancer sends a request on a connection just as Node closes it, the request fails and the client sees a 502.

    The fix is to make Node's timeout longer than the balancer's, for example server.keepAliveTimeout = 65_000, and to check the related limits: headersTimeout (60 s by default) and requestTimeout (5 minutes).

    On the client side, reuse connections too. fetch (undici) pools connections automatically, and since Node 19 the default http.Agent has keep-alive enabled. Opening a new connection for every outgoing request wastes CPU and can exhaust ephemeral ports.

    What interviewers listen for
    • Keep-alive reuses TCP connections across requests
    • Node closes idle connections after 5 s by default
    • Load balancers often keep them for 60 s
    • A close-versus-reuse race causes intermittent 502s
    • Set keepAliveTimeout above the balancer idle timeout

    Likely follow-up: What happens to keep-alive connections during a graceful shutdown?

  57. 58.Why does a Node.js process sometimes exit while a promise is still pending, and other times refuse to exit? What keeps it alive?hard

    Node exits when the event loop has nothing left to wait for: no active, ref'ed handles (servers, sockets, timers, child processes, file watchers) and no pending requests such as an in-flight file read or DNS lookup. A promise is not a handle. If nothing will ever settle it, the process just exits; with a top-level await in an ES module, Node warns about an unsettled top-level await and exits with code 13.

    Refusing to exit is the opposite problem: something is still ref'ed, often a database pool, an interval or a keep-alive socket. That's what makes CLI scripts and test runs hang at the end.

    Tools:

    • unref() on timers, servers and sockets lets the process exit even while they're active; ref() reverses it.
    • process.getActiveResourcesInfo() lists what's keeping the loop alive.
    • The beforeExit event fires when the loop empties, but not on process.exit().
    • process.exit() ends immediately and can cut off pending stdout writes; prefer setting process.exitCode and closing resources.
    import net from 'node:net';
    
    const server = net.createServer().listen(0); // active handle
    const timer = setInterval(() => {}, 1000); // active handle
    new Promise(() => {}); // not a handle: it doesn't keep the process alive
    
    console.log(process.getActiveResourcesInfo()); // [ 'TCPServerWrap', 'Timeout' ]
    server.unref();
    timer.unref();
    // Nothing ref'ed is left, so the process exits with code 0
    What interviewers listen for
    • Exits when no ref'ed handles or requests remain
    • Pending promises alone do not keep it alive
    • Open pools, sockets and intervals prevent exit
    • unref() stops a handle from blocking exit
    • Prefer process.exitCode over process.exit()
esc