Lesson 17 / 17
Debugging Tools
Connecting to the debugging interface, breakpoints and conditional breaks, watch expressions, stepping, and reading asynchronous call stacks.
Contents
Profiling told you where time is spent; it did not tell you what is wrong. For that, the program has to be stopped at a specific point and its variables examined.
This lesson takes up the second half of diagnostic tools. The examples will be built on the same processing step of the measurement stream; the mechanism used is the same debugging interface that interface-based tools also run on underneath.
The Debugging Interface
The runtime offers an interface that lets a client connected to it stop the program, read variables, and advance it step by step. Browser developer tools, editor extensions, and command-line clients all use the same interface.
There are three ways to connect. The runtime is started with the relevant option and a
client connects; it is started with the option that pauses at the start, so it is
tracked from the very first line; or the debugger statement is placed inside the code —
if a client is connected, execution stops there.
In the examples below, the program itself is used in place of a client: the same interface is connected to from inside the program. This shows directly what principles interface-based tools work on.
Breakpoint, Condition, and Watch Expressions
A breakpoint is where execution is to be stopped. At the stopping moment, two things can be examined: local variables’ values and the call stack.
Stopping at every breakpoint is useless in a highly repetitive flow. A conditional breakpoint stops only when the given expression is true. The program below achieves this by evaluating an expression at the stopping moment and continuing right away when the condition is not met.
import { Session } from "node:inspector"; const session = new Session(); session.connect(); session.post("Debugger.enable"); function readExpression(frame, expression) { let result = null; session.post( "Debugger.evaluateOnCallFrame", { callFrameId: frame.callFrameId, expression }, (error, response) => { result = response.result.value ?? response.result.description; }, ); return result; } session.on("Debugger.paused", (event) => { const frames = event.params.callFrames; const top = frames[0]; const value = readExpression(top, "measurement.value"); if (value > 20) { console.log("breakpoint:", top.functionName, "— line", top.location.lineNumber + 1); console.log(" watch: measurement.station =", readExpression(top, "measurement.station")); console.log(" watch: measurement.value =", value); console.log(" stack:", frames.slice(0, 3).map((c) => c.functionName).join(" <- ")); } session.post("Debugger.resume"); }); function processMeasurement(measurement) { debugger; return { station: measurement.station, rounded: Math.round(measurement.value) }; } function processStream(measurements) { return measurements.map(function oneMeasurement(measurement) { return processMeasurement(measurement); }); } const result = processStream([ { station: "A1", value: 21.4 }, { station: "B2", value: 19.8 }, { station: "C3", value: 23.1 }, ]); console.log("result:", result.map((s) => `${s.station}=${s.rounded}`).join(" ")); session.disconnect();
breakpoint: processMeasurement — line 34 watch: measurement.station = A1 watch: measurement.value = 21.4 stack: processMeasurement <- oneMeasurement <- processStream breakpoint: processMeasurement — line 34 watch: measurement.station = C3 watch: measurement.value = 23.1 stack: processMeasurement <- oneMeasurement <- processStream result: A1=21 B2=20 C3=23
Two of the three measurements met the condition and were reported; on the second
measurement the program continued as if it had never paused at all. The printed line
number is the line the debugger statement is on in the source file; if the code
changes, this number changes too.
A watch expression is an expression evaluated at the stopping moment. It does not
have to be just a variable name; an expression like measurement.value > 20, or even a
method call, can be evaluated too. In interface-based tools, these expressions are written
into a list and recalculated at every stop.
The call stack view comes from the same stop data too: processMeasurement was reached
through oneMeasurement, which was reached through processStream. This chain is the
answer to the question “where was this function called from.”
Stepping
There are three advancement commands at a stop. Step over moves to the next line; it does not enter called functions. Step into stops at the called function’s first line. Step out runs until the function currently in returns.
import { Session } from "node:inspector"; const session = new Session(); session.connect(); session.post("Debugger.enable"); let step = 0; session.on("Debugger.paused", (event) => { const top = event.params.callFrames[0]; console.log("step", step, "— line", top.location.lineNumber + 1); step += 1; if (step < 8) session.post("Debugger.stepOver"); else session.post("Debugger.resume"); }); function computeAverage(measurements) { debugger; let total = 0; for (const measurement of measurements) { total += measurement.value; } const average = total / measurements.length; return average; } const result = computeAverage([ { station: "A1", value: 21.4 }, { station: "B2", value: 19.8 }, ]); console.log("average:", result.toFixed(2)); session.disconnect();
step 0 — line 18 step 1 — line 19 step 2 — line 20 step 3 — line 20 step 4 — line 21 step 5 — line 20 step 6 — line 21 step 7 — line 20 average: 20.60
The line sequence exposes the loop’s structure: line 20 is the loop header and appears twice per round — once to take a value from the iterator, once to end the round. Line 21 is the body and runs twice for two measurements.
Stepping has a limit in asynchronous code: when an await expression is stepped over,
execution leaves that function and returns to the event loop. Its continuation runs on
another turn, once the relevant promise settles. For this reason, in asynchronous flows,
placing a breakpoint directly at the point of interest is more effective than stepping.
Asynchronous Call Stack
As shown in the course’s first topic, a callback runs on an empty stack. The diagnostic consequence of this is heavy: an error occurring inside a callback carries, on its stack, no information about who started that job.
Runtimes close this gap with asynchronous call stacks: the chain of functions bound to
each other with await is preserved and appears on the error stack marked with async.
function printFrames(error) { const frames = error.stack .split("\n") .slice(1) .map((line) => line.trim()) .filter((line) => !line.includes("node:")) .map((line) => line.replace(/\s*\(?file:.*$/, "")) .filter((line) => line !== "at async"); for (const frame of frames) console.log(" ", frame); } async function fetchMeasurement(station) { await new Promise((resolve) => setTimeout(resolve, 5)); throw new Error(`station did not respond: ${station}`); } async function readMeasurement(station) { return await fetchMeasurement(station); } async function processStream(stations) { const results = []; for (const station of stations) { results.push(await readMeasurement(station)); } return results; } function fetchWithCallback(station, callback) { setTimeout(() => callback(new Error(`station did not respond: ${station}`)), 5); } try { await processStream(["A1"]); } catch (error) { console.log("await chain —", error.message); printFrames(error); } fetchWithCallback("B2", (error) => { console.log("callback chain —", error.message); printFrames(error); });
await chain — station did not respond: A1 at fetchMeasurement at async readMeasurement at async processStream callback chain — station did not respond: B2 at Timeout._onTimeout
The difference is striking. In the await chain, the error’s path is visible start to
finish: which station was being read, which stream was being processed, all readable. In
the callback chain, there is only the timer frame; the information of who started the
request has been lost.
The file paths and the runtime’s internal frames in the output were filtered out here for printing; in a real stack, these are present too and vary by machine. Interface-based tools do the same filtering in the form of “hide library code.”
The design rule that follows adds one more to the reasoning from the course’s callback
lesson: code written with promises and await is not just more readable, it is more
diagnosable.
Debugging by Printing
Printing to the console looks primitive, but in some cases it is the only workable method: when the problem occurs rarely, is in production, or stopping itself changes the behavior.
Two improvements make it a serious tool. First, records carrying context: which station, which attempt, which idempotency key. A context-free “got here” line does not say which request it belongs to in an asynchronous flow.
Second, using a logpoint: it is set up like a breakpoint, but instead of stopping it prints an expression and continues. This way an observation can be added without changing or restarting the code.
Summary
- The debugging interface offers stopping the program, reading variables, and advancing step by step; interface-based tools use this same interface too.
- A breakpoint can be bound to a condition; watch expressions are evaluated at the stopping moment, and the call stack shows where the call came from.
- Stepping commands are step over, step into, and step out; when
awaitis stepped over, execution returns to the event loop. - An asynchronous call stack preserves the chain bound by
await; in callback-based code, this chain is lost. - Debugging by printing is a serious tool once records carry context and logpoints are used.
Course Wrap-Up
The course started with a single question: how does a single-threaded language meet waiting? The answer was built step by step.
First the model was built: user code runs on a single thread, to completion; the event loop takes one job from the task queue on every turn and then completely drains the microtask queue. This rule was the reasoning behind every output order in the course.
Then the abstraction was built: the gap the callback left — the inability to
compose, manually carrying the error path, contract violations — was closed with the
promise’s state machine. Combinators gave waiting its structure, async and await
fixed how it reads; both were layers sitting on top of the promise, not a new asynchrony
mechanism.
Next, control was built: the abort signal, timeouts, and retry patterns. The distinction here is the course’s most practical lesson — cutting the wait does not cut the job.
Finally, observation was built: the reachability-based memory model, leak diagnosis by snapshot comparison, main-thread profiling, and state inspection with breakpoints. The measurement stream example went from a single timer callback to a stream that is cancelable, has a timeout, is resilient, and leaves no leak.
What remains is how this code gets packaged and shipped. The next course — Modules, Tooling, and the Ecosystem — takes up module systems, how packages get resolved, dependency versioning, and build tools. This course touched on module format a few times and left the detail for there; its turn has come.
To keep your progress and take notes, Log in
My notes
Log in to take notes.