Debugging and Performance

Find and fix problems in Node.js apps: debug with breakpoints using --inspect, log in a structured way, measure timings with performance hooks, spot event-loop blocking, detect memory leaks, profile CPU use, and speed up slow endpoints with caching.

Lesson 24 of 43Advanced18 min read

Every developer spends a large part of their time finding out why something doesn't work, or why it's slow. Guessing and sprinkling console.log everywhere works for small problems, but real applications need better tools. Node.js comes with a full debugger, built-in performance measurement, profilers for CPU and memory, and diagnostics that show whether the event loop is healthy.

Debugging is about understanding what the code actually does: pausing at a line, inspecting variables, and stepping through. Performance work is about measuring before changing anything, because the slow part is rarely where you expect. A route might be slow because of one unindexed database query, a loop that calls the database a hundred times, a large synchronous JSON operation, or memory slowly leaking until the process is restarted.

In this lesson you'll use breakpoints with --inspect, write structured logs, time code precisely, measure event-loop delay, find a memory leak, profile CPU usage and make a slow endpoint fast with caching. The habit to build is simple: measure, change one thing, measure again.

Debugging with the inspector

Terminal
node --inspect server.js          # start with the debugger listening
node --inspect-brk script.js      # pause on the first line
node --watch --inspect server.js  # restart and keep debugging on save
  • Open chrome://inspect in Chrome, or use VS Code's Run and Debug panel (or its JavaScript Debug Terminal), to connect.
  • Set breakpoints by clicking line numbers, or write debugger; in the code.
  • While paused, you can inspect variables, see the call stack, step over or into functions, and evaluate expressions in the console.
  • Conditional breakpoints pause only when an expression is true, such as order.total > 1000.

Logging that helps

  • console.error and console.warn write to stderr; console.table shows arrays of objects as a table; console.dir(obj, { depth: null }) prints nested objects fully.
  • In production, write structured logs: one JSON object per line with a level, a message and useful fields such as requestId, userId and durationMs. Libraries such as pino do this very efficiently.
  • Use levels (debug, info, warn, error) and choose which to show with an environment variable.
  • Never log passwords, tokens, card numbers or other personal data.

Measuring performance

ToolWhat it shows
performance.now(), console.time()How long a piece of code takes
monitorEventLoopDelay() from node:perf_hooksHow long callbacks wait to run, a sign of blocking
node --cpu-prof app.jsA CPU profile to open in Chrome DevTools, showing which functions use the most time
process.memoryUsage()Heap and total memory in use
Heap snapshots in DevTools, node --heapsnapshot-signalWhich objects fill memory, to find leaks
Load testing tools such as autocannon or k6Requests per second and response times under load

Common fixes

  • Cache results that are expensive and change rarely, in memory with an expiry (TTL) or in Redis when you run several instances.
  • Avoid the N+1 query problem: one query for a list, then one more for each item. Load related data in a single query instead.
  • Add database indexes for columns you filter or sort by.
  • Stream large responses and compress them.
  • Move CPU-heavy work to worker threads or background jobs.
  • Don't JSON.parse or JSON.stringify enormous objects on the main thread in hot paths.

Node.js Advanced Syntax

Diagnostics toolbox

console.time('load');  await load();  console.timeEnd('load');
const t0 = performance.now();  /* work */  const ms = performance.now() - t0;
console.table(rows);
console.dir(obj, { depth: null });
debugger;                                   // pauses when a debugger is attached
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay();  h.enable();  /* later */  h.max / 1e6;   // ms
process.memoryUsage().heapUsed;
// node --inspect app.js   node --cpu-prof app.js   npx autocannon http://localhost:3000

Node.js Advanced Summary

  • node --inspect connects Chrome DevTools or VS Code for breakpoints, stepping and inspecting variables.
  • Use structured JSON logs with levels and request IDs; never log secrets.
  • performance.now and console.time measure code; monitorEventLoopDelay reveals blocking.
  • --cpu-prof and heap snapshots find slow functions and memory leaks.
  • Fix bottlenecks with caching, fewer queries, indexes, streaming and worker threads, measuring each change.

Node.js Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Timing code and structured logs

Node.js
function log(level, message, fields = {}) {
  console.log(JSON.stringify({ level, message, ...fields }));
}

function slowSum(n) {
  let total = 0;
  for (let i = 0; i < n; i++) total += Math.sqrt(i);
  return total;
}

const t0 = performance.now();
const result = slowSum(5_000_000);
const ms = performance.now() - t0;

log('info', 'Sum calculated', { result: Math.round(result), fast: ms < 2000 });
log('warn', 'Low stock', { product: 'mug', remaining: 2 });

console.table([
  { route: '/products', avgMs: 12 },
  { route: '/orders', avgMs: 87 },
]);
Output
{"level":"info","message":"Sum calculated","result":7453558807,"fast":true}
{"level":"warn","message":"Low stock","product":"mug","remaining":2}
┌─────────┬─────────────┬───────┐
│ (index) │ route       │ avgMs │
├─────────┼─────────────┼───────┤
│ 0       │ '/products' │ 12    │
│ 1       │ '/orders'   │ 87    │
└─────────┴─────────────┴───────┘
Line-by-line explanation
  1. Lines 1 to 3Each log line is a single JSON object, easy for log tools to search and filter.
  2. Lines 11 to 13performance.now gives precise timings in milliseconds.
  3. Line 15Log the outcome; this demo prints a check instead of the exact time, which varies by machine.
  4. Lines 18 to 21console.table shows arrays of objects as a readable table.
Example 2

Detecting a blocked event loop

Node.js
import { monitorEventLoopDelay } from 'node:perf_hooks';

const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();

function blockFor(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {}
}

await new Promise((r) => setTimeout(r, 200));
const before = histogram.max / 1e6;

blockFor(300);
await new Promise((r) => setTimeout(r, 50));
const after = histogram.max / 1e6;

histogram.disable();
console.log(`Max delay while idle under 100 ms: ${before < 100}`);
console.log(`Max delay after blocking over 250 ms: ${after > 250}`);
Output
Max delay while idle under 100 ms: true
Max delay after blocking over 250 ms: true
Line-by-line explanation
  1. Lines 3 to 4The histogram measures how late timers fire, which reveals blocking.
  2. Lines 6 to 9A busy loop blocks the event loop, like a heavy synchronous calculation in a route.
  3. Line 12Values are in nanoseconds, so dividing by a million gives milliseconds.
  4. Lines 15 to 16After the block, the maximum delay jumps; production monitoring alerts on this.
Example 3

Fixing a memory leak and adding a cache

Node.js
const leakyCache = new Map();
function leakyLookup(id) {
  if (!leakyCache.has(id)) leakyCache.set(id, { id, data: 'x'.repeat(1000) });
  return leakyCache.get(id);
}

class TtlCache {
  constructor(ttlMs, maxEntries) {
    this.ttlMs = ttlMs;
    this.max = maxEntries;
    this.map = new Map();
  }
  get(key, now = Date.now()) {
    const hit = this.map.get(key);
    if (!hit || hit.expires < now) { this.map.delete(key); return undefined; }
    return hit.value;
  }
  set(key, value, now = Date.now()) {
    if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value);
    this.map.set(key, { value, expires: now + this.ttlMs });
  }
}

for (let i = 0; i < 50_000; i++) leakyLookup(i);
console.log(`Leaky cache entries: ${leakyCache.size}`);

const cache = new TtlCache(60_000, 1000);
let dbCalls = 0;
function getProduct(id, now) {
  const cached = cache.get(id, now);
  if (cached) return cached;
  dbCalls++;
  const product = { id, name: `Product ${id}` };
  cache.set(id, product, now);
  return product;
}

for (let i = 0; i < 50_000; i++) getProduct(i % 200, 0);
console.log(`Bounded cache entries: ${cache.map.size}, database calls: ${dbCalls}`);
getProduct(5, 61_000);
console.log(`After expiry, database calls: ${dbCalls}`);
Output
Leaky cache entries: 50000
Bounded cache entries: 200, database calls: 200
After expiry, database calls: 201
Line-by-line explanation
  1. Lines 1 to 5A cache with no limit and no expiry grows forever: a classic memory leak in long-running servers.
  2. Line 15Expired entries are removed when they're read.
  3. Line 19When full, the oldest entry is removed, so memory stays bounded.
  4. Line 3850,000 requests for 200 products need only 200 database calls.
  5. Lines 40 to 41After the time limit passes, the next request fetches fresh data.

Common Node.js Advanced Mistakes

Leaving console.log debugging in production code

Don't
console.log('HERE 2', user);          // noisy, slow in hot paths, and may log personal data
Do
logger.debug({ userId: user.id }, 'Loaded user');   // levelled, structured, no sensitive fields

N+1 queries

Don't
const orders = await db.query('SELECT * FROM orders LIMIT 50');
for (const o of orders) o.customer = await db.query('SELECT * FROM customers WHERE id = $1', [o.customerId]);   // 51 queries
Do
const orders = await db.query(`
  SELECT o.*, c.name AS customer_name FROM orders o JOIN customers c ON c.id = o.customer_id LIMIT 50`);   // 1 query

Optimising without measuring

Don't
// rewrote the template engine for speed; the real cost was an unindexed query
Do
// profiled with --cpu-prof and timed queries first, then added the missing index

Node.js Advanced in Real Projects

Observability in production

Production Node services send structured logs to tools like Datadog, Grafana Loki or CloudWatch, export metrics such as request duration and event-loop delay, and use OpenTelemetry tracing to follow a request across services. When something is slow, engineers look at traces to find the slow step, reproduce it locally, profile it with --cpu-prof or heap snapshots, fix it, and confirm the improvement with a load test.

Node.js Advanced FAQ

How do I fix "JavaScript heap out of memory" in Node.js?

First check whether the program really needs that much memory or is leaking it, for example by holding every request in an array or loading a huge file at once; take heap snapshots to find out. If it genuinely needs more, raise the limit with node --max-old-space-size=4096 app.js, where the number is megabytes. Streaming the data instead of loading it all is usually the better long-term fix.

How do I debug a Node.js app running in Docker?

Start Node with --inspect=0.0.0.0:9229 so the debugger listens on all of the container's network interfaces, publish the port with -p 9229:9229, and connect from chrome://inspect or VS Code. Only do this on your own machine: anyone who can reach the debugger port can run code in your process.

Why are my Node.js stack traces cut off?

V8 records only the last 10 call frames by default, so deep call chains lose their beginning. Raise the limit with node --stack-trace-limit=50 app.js or by setting Error.stackTraceLimit = 50 early in your code. Longer traces cost a little time each time an error is created, so keep the default in hot production paths.

What is a flame graph and how do I make one for Node.js?

A flame graph shows a CPU profile as stacked bars: each bar is a function, its width is how much time was spent in it and the functions it called, and the bars above it are its callees. Record a profile with node --cpu-prof app.js, then open the .cpuprofile file in Chrome DevTools' Performance panel, which can show it as a flame chart. Wide bars near the top are the first places to look.

Why is my Node.js server fast in testing but slow under load?

Under load, small problems add up. Common causes are synchronous work that blocks the event loop for every request, a database connection pool that's too small so requests queue for a connection, missing indexes that only hurt with real data volumes, and the N+1 queries that grow with list size. Measure event loop delay and database timings during a load test to see which it is.

Node.js Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the command that starts Node with the debugger listening.

node -- server.js
Multiple choiceeasy

What does the debugger; statement do?

Multiple choicemedium

What does a high event-loop delay tell you?

Multiple choicemedium

What's the N+1 query problem?

Multiple choicehard

Why does a cache need a size limit or expiry in a long-running server?

Node.js Advanced Quiz

5 questions to check what you learned.

  1. Which method shows an array of objects as a table?

    • console.dir
    • console.table
    • console.grid
    • console.list
  2. What is structured logging?

    • Writing each log entry as data, such as one JSON object per line with fields
    • Logging only errors
    • Logging in a table
    • Logging to separate files
  3. Which flag records a CPU profile?

    • --profile-cpu
    • --inspect-cpu
    • --cpu-prof
    • --trace
  4. What should you do before optimising code for speed?

    • Rewrite it
    • Add more servers
    • Measure where the time actually goes
    • Remove logging
  5. Which tool helps find which objects are filling memory?

    • console.time
    • A heap snapshot in Chrome DevTools
    • npm audit
    • node --watch