14 Advanced Node.js Interview Questions for 2026

·18 min read
By ·Updated
nodejsinterview-questionsbackendevent-loopstreamsjavascript

Node.js can coordinate many concurrent I/O operations while JavaScript runs on a main event-loop thread. Its actual capacity depends on workload, dependencies, operating system, resource limits, and architecture—not a universal connection count. These questions test the runtime knowledge expected beyond everyday framework use.

Version context matters: as of September 2026, Node.js 24 is the latest LTS line and Node.js 26 is Current. Production applications should run a supported LTS line unless there is a deliberate reason to use Current.

Table of Contents

  1. Event Loop Questions
  2. Async Timing Questions
  3. Libuv Questions
  4. Streams Questions
  5. Scaling Questions
  6. Module System Questions
  7. Error Handling Questions
  8. Security Questions
  9. Quick Reference

Event Loop Questions

These questions test your understanding of Node.js's core async mechanism.

How does the Node.js event loop work?

The event loop is Node.js's mechanism for handling asynchronous operations on a single thread. It continuously cycles through phases—timers, pending callbacks, poll (for I/O), check (for setImmediate), and close callbacks—executing queued callbacks in each phase. This design lets Node.js handle thousands of concurrent connections without creating a thread for each one.

Think of the event loop like a theme park with different queues for different rides. Each "phase" is a different ride, and the event loop is the attendant who moves through each queue in order, letting people onto the ride before moving to the next.

flowchart TB
    T["timers<br/>← setTimeout, setInterval"] --> PC["pending callbacks<br/>← I/O callbacks deferred"]
    PC --> IP["idle, prepare<br/>← internal use"]
    IP --> P["poll<br/>← retrieve new I/O events"]
    P --> C["check<br/>← setImmediate callbacks"]
    C --> CC["close callbacks<br/>← socket.on('close')"]
    CC --> T

Here's how different async operations execute:

console.log('1: Start');
 
setTimeout(() => console.log('2: setTimeout'), 0);
setImmediate(() => console.log('3: setImmediate'));
process.nextTick(() => console.log('4: nextTick'));
Promise.resolve().then(() => console.log('5: Promise'));
 
console.log('6: End');

The guaranteed prefix is:

1: Start
6: End
4: nextTick
5: Promise

At the top level, the relative order of the zero-delay timer and setImmediate() is not guaranteed; either may appear next. After an I/O callback, setImmediate() normally wins because the loop reaches the check phase before the next timers phase. Also distinguish the dedicated next-tick queue from the V8 microtask queue used by Promises and queueMicrotask(). ESM evaluation itself runs as a microtask, which can change examples that compare these queues.


Async Timing Questions

These questions reveal understanding of callback scheduling priorities.

What is the difference between process.nextTick() and setImmediate()?

process.nextTick() runs callbacks after the current operation completes but before the event loop continues. setImmediate() runs callbacks in the check phase. process.nextTick() is now marked legacy by Node.js; use queueMicrotask() for most userland microtask deferral and reserve nextTick knowledge mainly for existing Node-specific code.

The naming is admittedly confusing—you'd think "immediate" would be faster than "next tick." But think of it this way: nextTick means "right now, before anything else" while setImmediate means "as soon as the current phase is done."

setImmediate(() => console.log('1: setImmediate'));
process.nextTick(() => console.log('2: nextTick'));
console.log('3: synchronous');
 
// Output:
// 3: synchronous
// 2: nextTick
// 1: setImmediate

The key insight is that process.nextTick() doesn't wait for the event loop at all. It queues the callback in a special "nextTick queue" that's processed after the current JavaScript execution completes but before the event loop moves on.

This has a dangerous implication:

// DON'T DO THIS - it starves the event loop
function recursiveNextTick() {
    process.nextTick(recursiveNextTick);
}
recursiveNextTick();
// The event loop never gets to process I/O!

The recursive call keeps the dedicated next-tick queue full, so the event loop never reaches poll to handle I/O. The process remains busy but stops making useful progress.

When should you use setImmediate() to break up long operations?

Use setImmediate() to cooperatively split modest synchronous work so the event loop can process other callbacks between chunks. This improves responsiveness but does not make CPU work parallel or cheaper. For sustained CPU-heavy work, use a bounded Worker pool, a separate process, or an external job system.

function processLargeArray(array, callback) {
    let index = 0;
 
    function processChunk() {
        const chunkEnd = Math.min(index + 1000, array.length);
 
        while (index < chunkEnd) {
            // Process item
            index++;
        }
 
        if (index < array.length) {
            setImmediate(processChunk); // Yield to event loop
        } else {
            callback();
        }
    }
 
    processChunk();
}

Libuv Questions

These questions reveal whether you understand Node.js at the C layer.

What is libuv and why is it critical to Node.js?

libuv is the C library that provides Node.js with its event loop, worker pool, and cross-platform I/O abstractions. It uses platform facilities such as epoll, kqueue, and IOCP where appropriate. The pool is used by most asynchronous file-system APIs, dns.lookup()/getaddrinfo, selected crypto operations, and zlib—not by arbitrary JavaScript automatically.

Node.js's famous non-blocking I/O isn't actually JavaScript magic—it's libuv doing the heavy lifting. Think of libuv as the engine under Node.js's hood.

flowchart TB
    App["Node.js Application Code<br/>(JavaScript)"]
    App --> Bindings["Node.js Bindings<br/>(C++)"]
    Bindings --> Libuv["libuv (C)"]
 
    Libuv --> EL["Event Loop"]
    Libuv --> TP["Thread Pool<br/>(4 threads default)"]
 
    EL --> Net["Network I/O<br/>(truly async)"]
    TP --> FS["File System<br/>DNS Lookups<br/>Crypto<br/>Compression"]

The key insight is that not all async operations are created equal. Network I/O is truly asynchronous at the OS level—libuv just registers callbacks with the OS. But file I/O on most systems is blocking, so libuv uses its thread pool to simulate async behavior.

// This uses the thread pool (file I/O)
const fs = require('fs');
fs.readFile('large-file.txt', (err, data) => {
    // One of the 4 thread pool threads handled this
});
 
// This is truly async (network I/O)
const http = require('http');
http.get('http://example.com', (res) => {
    // No thread pool needed - OS handles async
});

You can benchmark a different pool size when those specific APIs are the bottleneck. Set it before Node starts; a larger pool can increase contention and memory use and is not a generic performance switch:

UV_THREADPOOL_SIZE=8 node server.js

Streams Questions

These questions separate developers who've processed large files from those who haven't.

How do Node.js streams work and when should you use them?

Streams process data in chunks rather than loading everything into memory at once. There are four types: Readable (data source), Writable (data destination), Duplex (both read and write), and Transform (modify data as it passes through). Streams use backpressure to prevent fast producers from overwhelming slow consumers.

Think of streams like a factory assembly line versus a warehouse. Without streams, you'd load an entire file into a warehouse (memory), then move it all at once. With streams, items move through the factory piece by piece—you only need space for what's currently being processed.

// Without streams - loads entire file into memory
const fs = require('fs');
 
fs.readFile('huge-file.txt', (err, data) => {
    // 'data' is the ENTIRE file - could be gigabytes
    fs.writeFile('copy.txt', data, (err) => {
        console.log('Done');
    });
});
 
// With streams - processes in chunks
const readStream = fs.createReadStream('huge-file.txt');
const writeStream = fs.createWriteStream('copy.txt');
 
readStream.pipe(writeStream);
// Data is bounded by stream buffers rather than the whole file.

The real power comes from piping and transforming:

const { pipeline } = require('node:stream/promises');
const zlib = require('node:zlib');
 
await pipeline(
    fs.createReadStream('data.txt'),
    zlib.createGzip(),
    fs.createWriteStream('data.txt.gz')
);

pipeline() propagates errors and tears down the connected streams. Buffers keep memory bounded relative to configured watermarks, but memory is not literally constant and highWaterMark is a threshold rather than a hard global limit. Encryption should use a reviewed authenticated construction with createCipheriv(), a securely generated key and a unique nonce—not the removed password-based createCipher() example.

How does backpressure work in Node.js streams?

Backpressure occurs when your writable stream can't keep up with your readable stream. Without handling it, you'd buffer unlimited data in memory. The .write() method returns false when the internal buffer is full, signaling you should pause.

const readable = fs.createReadStream('huge-file.txt');
const writable = fs.createWriteStream('output.txt');
 
readable.on('data', (chunk) => {
    const canContinue = writable.write(chunk);
 
    if (!canContinue) {
        readable.pause(); // Stop reading until drain
        writable.once('drain', () => readable.resume());
    }
});

The .pipe() method handles backpressure automatically. Prefer stream.pipeline() or its Promise variant when you also need consistent error propagation and cleanup.


Scaling Questions

These questions test your understanding of Node.js's single-threaded limitation.

How do you scale Node.js across multiple CPU cores?

JavaScript in a normal Node.js process runs on one main thread, though the runtime and libuv also use supporting threads. Scale network services with multiple processes or container/VM replicas behind a load balancer. The built-in cluster module can fork processes that share a server port; Worker Threads are intended for CPU-intensive JavaScript, not ordinary asynchronous I/O.

The cluster module forks your process multiple times, and all children share the same server port:

const cluster = require('cluster');
const http = require('http');
const { availableParallelism } = require('node:os');
 
if (cluster.isPrimary) {
    console.log(`Primary ${process.pid} is running`);
 
    for (let i = 0; i < availableParallelism(); i++) {
        cluster.fork();
    }
 
    cluster.on('exit', (worker, code, signal) => {
        console.log(`Worker ${worker.process.pid} died`);
        cluster.fork(); // Restart dead workers
    });
} else {
    // Workers share the TCP connection
    http.createServer((req, res) => {
        res.writeHead(200);
        res.end(`Handled by worker ${process.pid}\n`);
    }).listen(8000);
 
    console.log(`Worker ${process.pid} started`);
}

Scheduling is platform-dependent: Node defaults to round-robin on most platforms, while Windows defaults to letting the operating system distribute connections. In orchestrated deployments, separate replicas and an external load balancer are often simpler operationally than in-process cluster management.

When should you use worker threads instead of the cluster module?

Worker threads are for CPU-intensive JavaScript that would otherwise block an event loop. Each Worker has its own V8 isolate and heap. Workers communicate by message passing, can transfer ArrayBuffer ownership, and can deliberately share SharedArrayBuffer; memory is not shared by default as if all objects belonged to one heap.

const { Worker, isMainThread, parentPort } = require('worker_threads');
 
if (isMainThread) {
    // Main thread
    const worker = new Worker(__filename);
 
    worker.on('message', (result) => {
        console.log('Fibonacci result:', result);
    });
 
    worker.postMessage(45); // Calculate fib(45)
 
    // Event loop is free to handle other requests!
} else {
    // Worker thread
    parentPort.on('message', (n) => {
        // CPU-intensive work happens here
        const result = fibonacci(n);
        parentPort.postMessage(result);
    });
}
 
function fibonacci(n) {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

The key difference is isolation: cluster workers are operating-system processes; Worker Threads are threads inside one process with separate JavaScript isolates. In production, use a reusable bounded Worker pool rather than spawning a new Worker for every small task, because startup and message-transfer overhead can exceed the benefit.


Module System Questions

These questions test your knowledge of Node.js's module evolution.

What is the difference between CommonJS and ES Modules?

CommonJS uses require() and module.exports and normally evaluates a module when it is required. ES modules use the JavaScript-standard import/export graph, live bindings, URL-based resolution, and support top-level await. Static imports are resolved and linked before module evaluation; import() returns a Promise and works from both module systems.

CommonJS was Node.js's original module system, designed before JavaScript had native modules:

// math.js (CommonJS)
function add(a, b) {
    return a + b;
}
module.exports = { add };
 
// app.js
const { add } = require('./math');
console.log(add(2, 3));

ES Modules are the JavaScript standard, added to Node.js later:

// math.mjs (ES Module)
export function add(a, b) {
    return a + b;
}
 
// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3));

The critical difference is the module model, not simply “sync versus async.” CommonJS exports an object and allows runtime require() calls. ESM links a statically analyzable dependency graph before evaluation and exposes live bindings. Interoperability has edge cases around default/named exports, file extensions, package.json type, and package exports conditions.

How do conditional imports differ between CommonJS and ES Modules?

CommonJS allows conditional imports anywhere because require() is just a function call. ES Modules require dynamic import() for conditional loading.

// CommonJS - conditional imports work
if (process.env.NODE_ENV === 'production') {
    const analytics = require('./analytics');
    analytics.track('startup');
}
 
// ES Modules - conditional imports need dynamic import()
if (process.env.NODE_ENV === 'production') {
    const { track } = await import('./analytics.mjs');
    track('startup');
}

Static ESM syntax gives bundlers better information for tree shaking, but Node.js itself does not tree-shake application modules. CommonJS is harder to analyze because require() can be data-dependent.

To use ES Modules in Node.js, add to package.json:

{
    "type": "module"
}

Or use the .mjs extension for individual files.


Error Handling Questions

These questions reveal whether you've debugged production Node.js applications.

How do you handle errors in async code?

For callbacks, follow the error-first pattern. Handle Promise rejection with .catch() or try/catch; for EventEmitters whose contract includes 'error', attach an error listener or consume them through an API such as pipeline() that propagates errors. At request and job boundaries, classify errors, record context without secrets, apply deadlines/cancellation, and avoid retrying non-idempotent work blindly.

Node.js has multiple async patterns, each with its own error handling approach. Missing any of them causes silent failures or crashes.

For callbacks, always check the error first:

fs.readFile('file.txt', (err, data) => {
    if (err) {
        console.error('Failed to read file:', err);
        return; // Don't continue with undefined data!
    }
    processData(data);
});

For Promises, errors propagate through the chain:

fetchUser(userId)
    .then(user => fetchOrders(user.id))
    .then(orders => processOrders(orders))
    .catch(err => {
        // Catches errors from ANY step above
        console.error('Pipeline failed:', err);
    });

With async/await, use try/catch:

async function handleRequest() {
    try {
        const user = await fetchUser(userId);
        const orders = await fetchOrders(user.id);
        return processOrders(orders);
    } catch (err) {
        console.error('Request failed:', err);
        throw err; // Re-throw if caller should handle it
    }
}

Why do unhandled event emitter errors crash the process?

Event emitters are designed to emit an 'error' event when something goes wrong. If no listener is attached, Node.js treats it as an unhandled exception and crashes the process to prevent silent failures.

const stream = fs.createReadStream('missing-file.txt');
 
// Without this, the process crashes
stream.on('error', (err) => {
    console.error('Stream error:', err);
});

Global hooks are observability and shutdown boundaries, not a substitute for local error handling. By default, an unhandled rejection is raised as an uncaught exception. uncaughtExceptionMonitor can record it without changing the crash behavior:

process.on('uncaughtExceptionMonitor', (err, origin) => {
    monitoring.logSync({ err, origin });
});

Let an external supervisor restart a crashed service. If you install uncaughtException to perform synchronous cleanup, set a non-zero exit code and do not resume normal operation—the process may be in an undefined state.


Security Questions

These questions test production readiness.

What are the security best practices for Node.js applications?

Key practices include validating untrusted input, context-aware output encoding, parameterized queries, authentication and authorization at every protected operation, rate limits and body-size limits, supported dependencies, TLS, minimal OS/container privileges, and safe logging. Security headers help browser-facing apps but are only one layer.

Start with security headers using helmet:

const helmet = require('helmet');
app.use(helmet()); // Sets 11 security headers by default

Validate and sanitize all input:

const { body, validationResult } = require('express-validator');
 
app.post('/user',
    body('email').isEmail().normalizeEmail(),
    body('name').trim().escape(),
    (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }
        // Process validated input
    }
);

Prevent SQL injection with parameterized queries:

// NEVER do this
const query = `SELECT * FROM users WHERE id = ${userId}`;
 
// Always do this
const query = 'SELECT * FROM users WHERE id = $1';
await pool.query(query, [userId]);

How should you manage secrets in Node.js applications?

Never commit secrets to source control. Environment variables are a delivery mechanism, not a secret manager: they can leak through process inspection, crash reports, logs, or child processes. Prefer a platform secret manager with workload identity, scoped access, rotation, and audit logs; load only the secrets the process needs.

// Never commit secrets to git
// Use environment variables
const dbPassword = process.env.DB_PASSWORD;
 
// Prefer a secret manager in production
const secrets = await secretManager.getSecret('db-credentials');

Keep dependencies secure:

npm audit                    # Check for vulnerabilities
npm outdated                # Check for updates

Review proposed upgrades and lockfile changes rather than applying npm audit fix blindly. Node's stable Permission Model can reduce accidental access to file system, network, child processes, workers, native addons, and other resources when started with --permission, but the Node project explicitly describes it as a seat belt—not a sandbox for malicious code. Use OS/container isolation for hostile code.

Rate limiting prevents abuse:

const rateLimit = require('express-rate-limit');
 
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100                   // 100 requests per window
});
 
app.use('/api', limiter);

Quick Reference

TopicKey Points
Event LoopPhases: timers → pending → poll → check → close; timer/immediate order depends on context
nextTick vs setImmediatenextTick is legacy and drains before the loop; setImmediate runs in check
libuvEvent loop, worker pool (four threads by default), cross-platform I/O
StreamsReadable, Writable, Duplex, Transform; prefer pipeline() for errors and backpressure
ClusterSeparate processes can share a port; isPrimary replaces deprecated isMaster
Worker ThreadsSeparate isolates for CPU work; explicit transfer/shared buffers; use a pool
CommonJS vs ESMRuntime require/object exports vs linked graph/live bindings/top-level await
Error HandlingHandle locally; global hooks log and terminate safely, not resume normal work

Official Sources


Frequently Asked Questions

What is the Node.js event loop and how does it work?

The event loop runs JavaScript callbacks through timers, pending callbacks, poll, check, and close-callback phases. Node.js delegates supported I/O to the operating system or libuv's worker pool, so one JavaScript thread can coordinate many concurrent operations. CPU-heavy JavaScript still blocks that loop unless it yields or moves to a worker.

What is the difference between process.nextTick() and setImmediate()?

process.nextTick() drains its separate queue before the event loop continues and can starve I/O when used recursively. It is now a legacy API; prefer queueMicrotask() for portable microtask deferral. setImmediate() runs in the check phase. Ordering against setTimeout(0) depends on scheduling context.

How do Node.js streams work and when should you use them?

Streams process data in chunks rather than loading everything into memory. There are four types: Readable, Writable, Duplex, and Transform. Use streams for large files, network data, or any scenario where loading entire data into memory would be inefficient or impossible.

What is libuv and why is it important for Node.js?

libuv provides the event loop, a worker pool, and cross-platform I/O abstractions. The pool defaults to four threads and is used by APIs including most asynchronous file-system calls, getaddrinfo/getnameinfo DNS operations, selected crypto functions, and zlib. CPU-heavy JavaScript does not automatically move to this pool.

How do you scale Node.js applications across multiple CPU cores?

A Node.js process normally runs JavaScript on one main thread. Scale HTTP workloads with multiple processes or replicas behind a load balancer; cluster can share a server port but is not the only deployment model. Use a Worker pool for CPU-intensive JavaScript. Workers have separate isolates and heaps, but can transfer ArrayBuffers or explicitly share SharedArrayBuffers.

What is the difference between CommonJS and ES Modules in Node.js?

CommonJS uses require() and module.exports and evaluates modules when required. ES modules use the standardized import/export graph, support top-level await, live bindings, URL-based resolution, and package exports/imports. Static imports are linked before evaluation; dynamic import() is asynchronous. Tree shaking is a bundler capability, not a promise made by Node.js itself.

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides