Node.js From Zero

Testing, Performance and Production Habits

node:test, profiling with --cpu-prof, memory leaks, configuration, Docker basics, and a production checklist.

Lesson 0815 min read#nodejs#testing#performance#production

On this page

  1. Words used in this lesson
  2. Testing with node:test
  3. Finding slow code with a CPU profile
  4. Memory: how to see a leak
  5. Configuration
  6. Docker basics for a Node service
  7. A production checklist
  8. Try it
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: a Node.js service is "production ready" when you can prove it works (tests), find out why it is slow or fat (profiles and memory numbers), configure it without editing code, and run it the same way everywhere (a small, safe container). Node ships with most of the tools you need: node:test, --cpu-prof, --heap-prof, heap snapshots and --env-file.

You now have a server that handles errors, logs well and shuts down politely (lesson 6), and you know how to use concurrency (lesson 7). This lesson is about the habits that keep it healthy once other people depend on it. The last lesson, the interview Q&A, will test you on all of it.

We will do five hands-on things: write tests with the built-in runner, find the slow function with a CPU profile, chase a memory leak with real numbers, load configuration from the environment, and look at a Dockerfile. For the wider testing picture (the test pyramid, mocks, end-to-end), see Testing Lesson 3 (integration and API tests) and Backend Lesson 11.

What I ran / checked. Everything ran on Linux with Node.js v22.22.0 (V8 12.4), plus Express 5.3.0 and Fastify 5.12.5 for the HTTP tests. Output is pasted from real runs; timings differ by machine. Sources I read (in my own words): the Node.js docs for the test runner, the command line flags (--cpu-prof, --heap-prof, --env-file, --watch, --max-old-space-size), process and v8; the goldbergyoni/nodebestpractices README (production and Docker sections). Could not reach: MDN and the Node.js website, so I used the raw docs on GitHub. Not verified: I did not build or run the Dockerfile, because only the Docker client was installed here and there was no running Docker daemon. Treat the Dockerfile as a reviewed example, not as tested output. I did not test Windows or macOS paths for the profile files. The --heap-prof output attribution is approximate (explained below).

Words used in this lesson

  • A unit test checks one small piece of code (a function) by itself.
  • An integration test checks several pieces working together, for example a route plus its validation.
  • An assertion is a check inside a test: "this value must equal that value".
  • A test runner is the program that finds tests, runs them and prints results.
  • A mock is a fake stand-in for a real dependency (a database, an HTTP call) that records how it was used.
  • Code coverage is the share of your code lines that ran during the tests.
  • A profile is a recording of where a program spends time (CPU profile) or memory (heap profile).
  • A flame graph / call tree is a picture or list of which functions called which, and how long each took.
  • Self time is time spent in a function's own code, not counting the functions it called.
  • The heap is the memory area where JavaScript objects live.
  • Garbage collection (GC) is the automatic cleanup that frees objects nobody can reach any more.
  • A memory leak is memory that is still reachable (so GC cannot free it) but is no longer needed. It grows over time.
  • A heap snapshot is a file with every object in the heap at one moment.
  • RSS (resident set size) is the total memory the operating system has given the process.
  • Environment variables are named text settings given to a process by its surroundings.
  • A twelve-factor style config means settings come from the environment, not from values written in code.
  • A Dockerfile is a recipe for building a container image.
  • Event loop delay is how late the event loop runs its timers. A high value means something blocks it.

Testing with node:test

Node has a built-in test runner, so for many projects you need no extra package. Files named *.test.mjs (or in a test folder) are found automatically when you run node --test.

A first test file

Code to test, math.mjs:

JavaScript
export const add = (a, b) => a + b;export function parsePort(value) {  const n = Number(value);  if (!Number.isInteger(n) || n < 1 || n > 65535) throw new RangeError(`bad port: ${value}`);  return n;}export async function fetchUser(id, getJson) {  const data = await getJson(`/users/${id}`);  return { ...data, name: data.name.toUpperCase() };}

The test file, test/math.test.mjs:

JavaScript
import { test, describe, it, mock } from "node:test";import assert from "node:assert/strict";import { add, parsePort, fetchUser } from "../math.mjs";test("add adds", () => { assert.equal(add(2, 3), 5); });describe("parsePort", () => {  it("accepts a valid port", () => assert.equal(parsePort("8080"), 8080));  it("rejects text", () => assert.throws(() => parsePort("abc"), RangeError));  it("rejects 0", () => assert.throws(() => parsePort(0), { name: "RangeError", message: "bad port: 0" }));});test("fetchUser upper-cases the name (with a mock)", async () => {  const getJson = mock.fn(async () => ({ id: 1, name: "ada" }));  const user = await fetchUser(1, getJson);  assert.deepEqual(user, { id: 1, name: "ADA" });  assert.equal(getJson.mock.callCount(), 1);  assert.deepEqual(getJson.mock.calls[0].arguments, ["/users/1"]);});test("async rejection", async () => {  await assert.rejects(fetchUser(1, async () => { throw new Error("net down"); }), /net down/);});test("this one fails on purpose", () => { assert.equal(add(2, 2), 5); });test.skip("skipped", () => {});test.todo("write later");

A few things to notice:

  • node:assert/strict makes assert.equal behave like ===. Always import the strict one. The plain node:assert has a loose equal that uses == and can pass wrong tests.
  • describe/it are just names for grouping. test alone is fine.
  • mock.fn() makes a fake function that records every call. We passed it into fetchUser. Passing dependencies as arguments (instead of importing them inside) is what makes code easy to test.
  • assert.throws and assert.rejects check for errors. Note that you must await the rejects check, or the test finishes before it is verified.

I ran node --test --test-reporter=spec:

TEXT
✔ add adds (0.829276ms)▶ parsePort  ✔ accepts a valid port (0.281521ms)  ✔ rejects text (1.606129ms)  ✔ rejects 0 (1.14609ms)✔ parsePort (3.428563ms)✔ fetchUser upper-cases the name (with a mock) (0.787451ms)✔ async rejection (0.606447ms)✖ this one fails on purpose (0.998364ms)﹣ skipped (0.493429ms) # SKIP✔ write later (0.307442ms) # TODOℹ tests 9ℹ suites 1ℹ pass 6ℹ fail 1ℹ cancelled 0ℹ skipped 1ℹ todo 1✖ failing tests:test at test/math.test.mjs:25:1✖ this one fails on purpose (0.998364ms)  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:  4 !== 5

The failing test shows the useful part: the file and line, and 4 !== 5 (actual versus expected). (When the output goes into a pipe instead of a terminal, Node's default is a machine-readable format, so I picked the spec reporter to get the readable list. The count of 9 includes the suite, the skipped and the todo.)

I then fixed the wrong expectation (5 to 4) and ran again with coverage turned on:

TEXT
ℹ tests 9ℹ pass 7ℹ fail 0ℹ file           | line % | branch % | funcs % | uncovered linesℹ math.mjs       | 100.00 |   100.00 |  100.00 |ℹ  math.test.mjs | 100.00 |   100.00 |   92.31 |ℹ all files      | 100.00 |   100.00 |   93.75 |

The flag is --experimental-test-coverage. The name still says experimental in the Node 22 docs I read. Treat the numbers as a guide: 100% coverage means every line ran, not that every behaviour is checked.

Useful flags

  • node --test --test-name-pattern="404" runs only the tests whose name matches. I used it and only the one matching test ran.
  • node --test --watch reruns tests when files change (the docs list --watch as a general Node flag). I did not run it interactively here.
  • node --test --test-reporter=spec for a readable list.

Testing an HTTP app

Make the app a function that takes its dependencies, so a test can pass a fake database:

JavaScript
// app.mjsimport express from "express";export function buildApp({ db }) {  const app = express();  app.use(express.json());  app.get("/users/:id", async (req, res) => {    const user = await db.find(Number(req.params.id));    if (!user) return res.status(404).json({ error: "not found" });    res.json(user);  });  app.post("/users", async (req, res) => {    if (typeof req.body?.name !== "string") return res.status(400).json({ error: "name required" });    res.status(201).json(await db.insert(req.body.name));  });  app.use((err, req, res, next) => res.status(500).json({ error: "internal error" }));  return app;}

The test starts the real app on port 0, which means "pick any free port", so tests never clash on a port number:

JavaScript
import { test, before, after } from "node:test";import assert from "node:assert/strict";import { buildApp } from "../app.mjs";const fakeDb = {  find: async (id) => (id === 1 ? { id: 1, name: "Ada" } : null),  insert: async (name) => ({ id: 2, name }),};let server, base;before(async () => {  server = buildApp({ db: fakeDb }).listen(0);  await new Promise((r) => server.once("listening", r));  base = `http://localhost:${server.address().port}`;});after(() => server.close());test("GET existing user", async () => {  const res = await fetch(`${base}/users/1`);  assert.equal(res.status, 200);  assert.deepEqual(await res.json(), { id: 1, name: "Ada" });});test("GET missing user is 404", async () => {  assert.equal((await fetch(`${base}/users/99`)).status, 404);});test("POST without name is 400", async () => {  const res = await fetch(`${base}/users`, {    method: "POST", headers: { "content-type": "application/json" }, body: "{}",  });  assert.equal(res.status, 400);});test("a throwing db becomes a 500 without details", async () => {  const bad = buildApp({ db: { find: async () => { throw new Error("secret sql"); } } }).listen(0);  await new Promise((r) => bad.once("listening", r));  const res = await fetch(`http://localhost:${bad.address().port}/users/1`);  assert.equal(res.status, 500);  assert.equal(JSON.stringify(await res.json()).includes("secret"), false);  bad.close();});
TEXT
✔ GET existing user (102.413234ms)✔ GET missing user is 404 (9.880202ms)✔ POST without name is 400 (25.363269ms)✔ a throwing db becomes a 500 without details (22.313446ms)ℹ tests 4ℹ pass 4ℹ fail 0

before and after run once around the tests in the file. The last test checks a security promise: the error handler must not leak secret sql. Good error-path tests are cheap and catch real incidents.

With Fastify you do not even need a port. app.inject runs a request through the whole pipeline in memory:

JavaScript
test("fastify inject needs no port", async (t) => {  const app = Fastify(); t.after(() => app.close());  app.get("/ping", async () => ({ pong: true }));  const res = await app.inject("/ping");  assert.equal(res.statusCode, 200);  assert.deepEqual(res.json(), { pong: true });});
TEXT
✔ fastify inject needs no port (80.256943ms)

Fake timers

Never make a test wait 60 seconds for a timer. The runner can fake time:

JavaScript
test("fake timers", (t) => {  t.mock.timers.enable({ apis: ["setTimeout"] });  let called = false; setTimeout(() => { called = true; }, 60_000);  assert.equal(called, false);  t.mock.timers.tick(60_000);  assert.equal(called, true);});
TEXT
(node:6234) ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time✔ fake timers (2.074367ms)

It works, but Node printed a warning that the mock timers API is still experimental in this version, so check the docs for your Node version before relying on it.

Traps in testing.

  • Tests that pass for the wrong reason. A test that forgot to await an async assertion passes even when the code is wrong. Make sure every promise in a test is awaited or returned.
  • Mocking too much. If you mock everything, you test your mocks. Mock the slow, outside things (database, network, clock); keep your own logic real.
  • Tests that share state. A global array or a shared database row makes tests order-dependent. Build fresh state in each test.
  • Always close servers and timers in after. An open server keeps the test process alive.

Finding slow code with a CPU profile

If a service is slow, do not guess. Measure. A CPU profile samples the program many times a second and records the function that was running. Node can write one for you with --cpu-prof. Here is a small script with two slow functions:

JavaScript
function slowSum(n) { let s = 0; for (let i = 0; i < n; i++) s += Math.sqrt(i); return s; }function parseLots() { let t = 0; for (let i = 0; i < 200000; i++) t += JSON.parse('{"a":' + i + '}').a; return t; }function main() { slowSum(3e8); parseLots(); }main();

Run it with the flag:

TEXT
$ node --cpu-prof --cpu-prof-dir=prof slow.mjs$ ls profCPU.20261011.184154.6420.0.001.cpuprofile

The .cpuprofile file is JSON. You can open it in Chrome DevTools (the Performance or "JavaScript Profiler" panel, or chrome://inspect tools) to see a flame graph, and VS Code can open it too. I did not open it in a browser here. Instead I wrote 15 lines of Node that add up the self time per function from the file:

TEXT
   616 ms  88% slowSum slow.mjs:1    34 ms   5% parseLots slow.mjs:2    17 ms   2% (program) :0    11 ms   2% (garbage collector) :0     5 ms   1% main slow.mjs:3     3 ms   0% compileFunction :0

The answer is obvious now: 88% of the time is in slowSum. Not parseLots, which a human might have guessed because JSON feels slow. Two lessons from profiles:

  1. The hot spot is rarely where you think. Fix the top line first, measure again.
  2. Look at the self time, not just the total. main has almost no self time, but it is the parent of everything.

In a web server, you do the same: start the server with --cpu-prof, send realistic load, stop it with a graceful shutdown (the profile is written when the process exits normally), and open the file.

Other tools worth knowing: --inspect opens a debugging port you can attach Chrome DevTools or VS Code to (be careful: never expose it on a public network, it gives full control of the process), and node --prof, an older V8 profiler. I did not use them here.

Measuring event loop delay

You can also measure the thing from lesson 7 directly. monitorEventLoopDelay samples how late the loop runs:

JavaScript
import { monitorEventLoopDelay } from "node:perf_hooks";const h = monitorEventLoopDelay({ resolution: 10 }); h.enable();setTimeout(() => { const t = Date.now(); while (Date.now() - t < 300); }, 100);   // block 300 mssetTimeout(() => { h.disable(); console.log("max lag ms:", Math.round(h.max / 1e6), "p50 ms:", Math.round(h.percentile(50) / 1e6)); }, 600);
TEXT
max lag ms: 309 p50 ms: 10

I blocked the loop for 300 ms on purpose, and the histogram's maximum was 309 ms. The median stayed at 10 ms (the sampling resolution). In production, export max or the 99th percentile as a metric and alert when it rises. It is the best single number for "is my Node process healthy".

Memory: how to see a leak

Node's JavaScript memory is automatically cleaned by the garbage collector. A leak happens when your code keeps a reference to things it no longer needs, so GC cannot free them. The usual suspects: a global Map or array that only grows (a cache with no limit), event listeners that are added but never removed, closures that hold big objects, timers that are never cleared.

The numbers from process.memoryUsage()

JavaScript
console.log(process.memoryUsage());
TEXT
{  rss: 43810816,  heapTotal: 5345280,  heapUsed: 3775680,  external: 1302304,  arrayBuffers: 10511}
  • heapUsed: bytes of live JavaScript objects (what you usually watch).
  • heapTotal: heap size the engine has reserved.
  • rss: everything the OS gave the process (heap, code, stacks, buffers).
  • external and arrayBuffers: memory outside the heap, such as Buffers.

The heap has a default upper limit that depends on the machine. On mine v8.getHeapStatistics().heap_size_limit said about 8,240 MB. You can change it with --max-old-space-size=<MB>.

A leaking handler, shown with numbers

Here is a classic bug. A module-level Map caches data by request id. Request ids are unique, so the cache never gets a hit and never shrinks:

JavaScript
const cache = new Map();                       // module-level, lives foreverfunction handle(requestId) {  const payload = { requestId, data: new Array(100).fill(requestId) };   // ~1 KB of data per request  if (mode === "leak") cache.set(requestId, payload);        // BUG: unique key, never deleted  else { cache.set(requestId, payload); if (cache.size > 1000) cache.delete(cache.keys().next().value); } // FIX: bounded}

I simulated 100,000 requests per round, forced a garbage collection after each round (with --expose-gc and global.gc(), so we see only what is really retained), and printed memory:

TEXT
leak after  100000 requests | heapUsed   91.9 MB | rss  165.4 MB | cache size 100000leak after  200000 requests | heapUsed  180.1 MB | rss  257.9 MB | cache size 200000leak after  300000 requests | heapUsed  271.8 MB | rss  352.9 MB | cache size 300000leak after  400000 requests | heapUsed  356.5 MB | rss  440.1 MB | cache size 400000leak after  500000 requests | heapUsed  441.2 MB | rss  530.1 MB | cache size 500000

A straight line upward, even after GC. About 0.9 KB per request, matching the data size. The line never flattens. That is the signature of a leak.

Now the fixed version, where the cache holds at most 1000 entries (a simple "delete the oldest" rule):

TEXT
fixed after  100000 requests | heapUsed    4.6 MB | rss  162.8 MB | cache size 1000fixed after  200000 requests | heapUsed    4.7 MB | rss  163.5 MB | cache size 1000fixed after  300000 requests | heapUsed    4.7 MB | rss  163.5 MB | cache size 1000fixed after  400000 requests | heapUsed    4.7 MB | rss  163.7 MB | cache size 1000fixed after  500000 requests | heapUsed    4.7 MB | rss  163.9 MB | cache size 1000

Flat. Notice something else: in the fixed run rss stayed at 163 MB while heapUsed was 4.7 MB. The OS-level number includes memory Node reserved earlier and the engine does not hand back instantly. That is why you track heapUsed after GC over time, not RSS alone. (A "bounded" cache by hand is for demo; for real use, an LRU cache library with a size and a time limit is better.)

What happens at the limit

I set a small heap limit and filled a Map until it ran out:

TEXT
$ node --max-old-space-size=64 leak2.mjs...Mark-Compact (reduce) 66.6 (72.4) -> 64.8 (67.6) MB ...FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryexit code: 134

The process died with exit code 134 (a crash). You cannot catch this error in JavaScript. In production this shows up as the container being restarted again and again, often after hours of slow memory growth. In a container with a memory limit, the platform may also kill the process before V8 reaches its own limit (an "OOM kill"), so set --max-old-space-size below the container limit and leave room for non-heap memory.

Finding the culprit with a heap snapshot

Seeing that memory grows is step one. Step two: what is growing? A heap snapshot lists every object. In code you can write one with v8.writeHeapSnapshot(); you can also trigger it with --heapsnapshot-signal=SIGUSR2 or through the Chrome DevTools "Memory" tab after --inspect. I wrote a snapshot of a small version (20,000 cache entries) and summed the objects by type with a short script:

TEXT
total nodes 114591  20100 objects   15.6 MB  array:(object elements)    314 objects    1.2 MB  array:  20662 objects    0.8 MB  object:Object  20129 objects    0.6 MB  object:Array

The biggest item is about 20,000 arrays holding 15.6 MB: exactly the data arrays of the cache entries (one per request). A real investigation uses the DevTools comparison view:

  1. Take snapshot A after the app has warmed up.
  2. Run traffic for a while.
  3. Take snapshot B.
  4. In DevTools, compare B to A and sort by "size delta". Objects that only grow are your leak.
  5. Click an object and read its retainers: the chain of references that keeps it alive. That chain leads to your global Map.

A surprise I hit while making the snapshot. My first snapshot was missing the cache entirely. The Map was a top-level variable that the script never used again, and V8 had already cleaned it up. Garbage collection frees anything that is no longer reachable, even if the variable is still "in scope" in the source. I added a console.log(cache.size) after the snapshot to keep it alive. When you debug memory, remember that the engine, not the source code, decides what is alive.

Heap snapshots can be huge. A snapshot of my 600,000-entry run was about 557 MB, which was too big for my little reader script, so I used a smaller run for the analysis. Take snapshots when memory is growing but before it is enormous. Also, while a snapshot is written the process is paused, so do it on one instance, not the whole fleet at once.

--heap-prof: allocation sampling

--heap-prof writes a sampling heap profile: which functions allocated the memory that is still alive at exit.

TEXT
$ node --expose-gc --heap-prof --heap-prof-dir=hp leak.mjs leak  298.3 MB (anon) leak.mjs:1  143.6 MB handle leak.mjs:5   14.0 MB set :0

It points at handle in my file, which is right. The first line is a module-level function with a nameless label that appears to hold allocation data that belonged to the same code; I did not dig into exactly why V8 attributed it that way, so treat the attribution as a hint to look near those lines, not as an exact answer. The tool is sampling-based, and the numbers are estimates.

Common leak patterns checklist

  • Unbounded Map, Set or array used as a cache or a registry.
  • emitter.on(...) inside a request handler, without off. Node prints MaxListenersExceededWarning at more than 10 listeners per event (I triggered it with 11): treat that warning as a real bug alert, not as noise.
  • Timers (setInterval) never cleared.
  • Closures that capture a large object ("the whole request") when they need one field.
  • Storing request or response objects in a global structure.

Configuration

Settings that change between environments (port, database URL, API keys, log level) should come from environment variables, not from code. The same build then runs in dev, test and production. Never commit secrets.

Node can load a .env file with a flag, with no extra package:

TEXT
# .envPORT=5050API_KEY=abc123# commentGREETING="hello world"

And a small config module that checks everything at start-up and fails fast if something is missing:

JavaScript
const required = ["PORT", "API_KEY"];const missing = required.filter((k) => !process.env[k]);if (missing.length) { console.error("Missing env vars:", missing.join(", ")); process.exit(1); }const config = Object.freeze({  port: Number(process.env.PORT),  apiKey: process.env.API_KEY,  greeting: process.env.GREETING ?? "hi",});console.log({ ...config, apiKey: config.apiKey.slice(0, 2) + "***" }, typeof config.port);
TEXT
$ node cfg.mjsMissing env vars: PORT, API_KEYexit 1$ node --env-file=.env cfg.mjs{ port: 5050, apiKey: 'ab***', greeting: 'hello world' } number$ PORT=1 node --env-file=.env cfg.mjs{ port: 1, apiKey: 'ab***', greeting: 'hello world' } number

Three useful facts from this run:

  • Fail fast. A server that starts with a missing setting and crashes at the first request is much harder to debug than one that refuses to start with a clear message.
  • Environment variables are always text. process.env.PORT is the string "5050". I converted it with Number(...). A common bug is if (process.env.DEBUG) when the value is the string "false", which is truthy.
  • Real environment beats the file. With PORT=1 set on the command line, it won over PORT=5050 in .env. The --env-file flag was added in Node 20.6, and the docs I read say it is no longer experimental in Node 22.21 and later. Check your version.

In real systems, secrets come from a secret manager or from the platform's encrypted settings, not from a file in the repository. Add .env to .gitignore and .dockerignore. See Security Lesson 5 (secrets).

Docker basics for a Node service

A container packages your app and its dependencies, so it runs the same everywhere. The nodebestpractices guide has a whole section on this. The most relevant points for us are listed after the example. This Dockerfile is a reviewed example that I did not build here (no Docker daemon in my environment):

Shell
# 1. Pin a specific Node version (not just "latest")FROM node:22-slim# 2. Production mode helps some libraries skip debug workENV NODE_ENV=productionWORKDIR /app# 3. Copy only the dependency files first, so Docker can cache this layerCOPY package.json package-lock.json ./RUN npm ci --omit=dev# 4. Then copy the sourceCOPY . .# 5. Do not run as rootUSER nodeEXPOSE 3000# 6. Start node directly so it receives SIGTERMCMD ["node", "server.js"]

Why each line matters:

  1. Pin the version so a rebuild does not silently change Node.
  2. NODE_ENV=production: for example Express turns off verbose error pages (we saw this in lesson 5).
  3. Layer caching: the dependency layer is rebuilt only when package*.json changes, which makes builds much faster.
  4. npm ci installs exactly what the lock file says. --omit=dev leaves out dev tools.
  5. Non-root user. If an attacker gets code execution in your app, they should not be root. The official Node images include a node user.
  6. CMD ["node", "server.js"] (the "exec form" with a JSON array) runs Node as the main process. The nodebestpractices guide says to avoid npm start there, because npm may not forward SIGTERM to your app, which breaks graceful shutdown from lesson 6.

Also create a .dockerignore file listing node_modules, .git, .env and test files, so they do not enter the image. For deployment in more depth, read DevOps Lesson 9 (Docker).

A production checklist

Use this as a review list before you ship a Node service. Each item is something you have now seen run.

Code and errors

  • Central error handler; no stack traces or messages sent to clients for 5xx.
  • unhandledRejection and uncaughtException: log and exit (lesson 6).
  • Input validated at the edge with schemas (lesson 5).
  • Timeouts on every outgoing call; concurrency limits on bulk work (lesson 7).
  • No sync file or crypto functions in request handlers; heavy CPU work in workers or a job queue.

Operations

  • JSON logs to stdout with a request id; secrets redacted.
  • /health and /ready; graceful shutdown on SIGTERM, tested with curl and kill.
  • Config from environment, validated at start-up.
  • Metrics: event loop delay, heapUsed, request rate, error rate, latency percentiles.
  • A memory limit and --max-old-space-size below it.

Delivery

  • Tests in CI: node --test, including error paths.
  • npm ci with a lock file; run npm audit and keep dependencies updated (see Security Lesson 6).
  • Small container, non-root user, pinned base image, CMD ["node", ...].
  • A way to roll back.

Try it

  1. Write a function slugify(text) and 5 tests with node:test, including an empty string and a very long string.
  2. Add a test that uses mock.fn() to prove a function calls a dependency exactly once.
  3. Run any slow script of yours with --cpu-prof and find the top function by self time (use the 15-line script idea or open the file in Chrome DevTools).
  4. Reproduce the leak: a Map that grows. Print heapUsed after global.gc() for 5 rounds. Then add a size limit and compare.
  5. Create a config module that reads PORT and LOG_LEVEL, fails fast when PORT is missing, and converts PORT to a number. Test it with --env-file.

Answers

  1. Ideas for edge cases: slugify("") returns ""; text with accents and spaces; a 100,000 character input should not take noticeable time.
  2. const f = mock.fn(); run(f); assert.equal(f.mock.callCount(), 1);.
  3. Sort the samples by self time. The first row is where to start. Re-run after your fix and compare the totals.
  4. Without a limit heapUsed after each GC grows each round. With a limit it stays flat.
  5. If process.env.PORT is undefined, print a message and process.exit(1). With the flag: node --env-file=.env app.mjs.

Interview questions

Why would you use the built-in node:test runner? (Must know)

It ships with Node, so there is nothing to install or configure. It supports test, describe/it, hooks like before and after, mocks, coverage and several reporters, and it runs files in parallel processes. For a service or a small library it is enough. A bigger team might still choose Jest or Vitest for features like snapshot tests, watch UIs or wide ecosystem plugins. I choose based on what the project needs, not habit.

How do you test an HTTP route without a real database? (Must know)

I build the app with a factory function, buildApp({ db }), and pass a fake database in the test. I start it on port 0 (any free port) and call it with fetch, then check status and body. For Fastify, app.inject skips the network entirely. I test the happy path, the validation error and a throwing dependency, and I check that the 500 response does not contain internal details. I still keep a few tests against a real database in CI, because mocks cannot catch SQL mistakes.

What is the danger of using node:assert instead of node:assert/strict? (Trap)

The plain module has assert.equal that compares with ==, so assert.equal(1, "1") passes. The strict module uses === and strict deep equality. I always import node:assert/strict, so a test cannot pass because of type coercion.

A test passes but the code is wrong. Name two causes. (Trap)

First, a forgotten await on an async assertion such as assert.rejects: the test ends before the check runs. Second, over-mocking: the test compares the mock to itself. Others are shared state between tests and asserting too little (only that "no error was thrown"). I guard against them with awaited assertions, real logic under test, and by occasionally breaking the code on purpose to see the test fail.

Your Node service is slow. How do you find out why? (Must know)

I measure instead of guessing. I start with metrics: is it CPU, memory, event loop delay, or waiting on a dependency? For CPU I run with --cpu-prof, send realistic load, exit cleanly, and look at self time per function in Chrome DevTools or a script. In my test 88% of the time was in one function that I would not have picked by eye. For event loop problems I use monitorEventLoopDelay. Then I fix the top item and measure again.

How do you know that a Node process has a memory leak? (Must know)

I watch heapUsed over time, ideally after a forced GC in a test, or in production using the metrics from a normal GC cycle. A leak is a line that keeps rising and never returns to a baseline. In my experiment heapUsed rose by about 90 MB for every 100,000 requests with an unbounded cache, and stayed at 4.7 MB with a bounded one. rss alone is misleading, because Node keeps memory it has reserved.

How do you find what is leaking? (Senior signal)

I take a heap snapshot after warm-up, run traffic, take another, and use the comparison view in Chrome DevTools to sort by size delta. Then I look at the retainers of the growing objects: the reference chain that keeps them alive. It usually ends at a global Map, an event emitter listener, or a closure. I can also use --heap-prof for allocation sampling. I take snapshots on a single instance, since the process pauses while writing and the file can be hundreds of MB.

What causes common Node memory leaks? (Must know)

Unbounded caches and registries (a Map keyed by request id), event listeners added per request and never removed, timers never cleared, closures that capture large objects, and storing request objects in globals. Node helps a little: it warns with MaxListenersExceededWarning when more than 10 listeners are added for one event, which I reproduced with 11. I treat that warning as a bug, not noise.

What happens when Node runs out of heap, and can you catch it? (Trap)

V8 prints a fatal error ("JavaScript heap out of memory") and the process aborts; I saw exit code 134. It is not a JavaScript exception, so try/catch and uncaughtException cannot handle it. The practical defences are a bounded design, memory metrics with alerts, a sensible --max-old-space-size below the container limit, and a supervisor that restarts the process.

How do you manage configuration in a Node service? (Must know)

From environment variables, read once at start-up into a frozen config object, validated, with types converted (everything in process.env is a string). If something required is missing, the service refuses to start and says which variable. Locally I can use node --env-file=.env, and the real environment wins over the file, as I tested. Secrets come from a secret manager or the platform, never from the repository.

What are the most important lines in a Node Dockerfile? (Senior signal)

A pinned base image, copying package*.json and running npm ci --omit=dev before copying the source for cache reuse, USER node so it does not run as root, NODE_ENV=production, and CMD ["node", "server.js"] in exec form so Node is the main process and receives SIGTERM. Together with a .dockerignore that keeps .env, .git and node_modules out of the image. I should say honestly that I validated the reasons from the docs and guide, not by building the image in this lesson.

What would you put on a production-readiness checklist for a Node API? (Senior signal)

Central error handling and no leaked internals; validation at the edge; timeouts and concurrency limits; structured logs with request ids; health and readiness endpoints; graceful shutdown tested with a real signal; config from the environment, validated at boot; tests in CI including error paths; metrics for event loop delay, heap, latency and errors; a memory limit; non-root minimal container; dependency audit; and a rollback path. The point is that each item prevents a failure I have actually seen or reproduced.

Key takeaways

  • node:test is built in. Use node:assert/strict, pass dependencies in so you can fake them, start HTTP apps on port 0, and test error paths.
  • Do not guess about speed. Use --cpu-prof and read self time: 88% of my run was in one function.
  • monitorEventLoopDelay shows when something blocks the loop (my blocked 300 ms showed as a 309 ms maximum).
  • A leak is a line that keeps rising in heapUsed after GC. A bounded cache stayed at 4.7 MB while an unbounded one reached 441 MB in the same run.
  • Heap snapshots plus the comparison view and retainers find what is growing. The engine, not your variable scope, decides what is alive.
  • Running out of heap kills the process (exit code 134) and cannot be caught.
  • Read config from the environment, validate at start-up, convert types, and let the real environment win over .env.
  • A good Dockerfile pins the version, caches dependencies, runs as non-root, and starts node directly so it gets SIGTERM. (Not built in this lesson.)

What's next

Next: Node.js Interview Questions With Model Answers. More than twenty questions on the event loop, streams, errors, scaling and security, each with a model answer and the trap to avoid, and several backed by tiny experiments. Practise now: pick the memory leak experiment, run it yourself, and explain the output out loud as if to an interviewer.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series