---
title: 'Debugging Tools'
source: 'https://academia.sh/en/courses/asynchronous-javascript/debugging-tools'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-17T18:09:44+00:00'
license: 'CC BY-SA 4.0'
---

# Debugging Tools

Connecting to the debugging interface, breakpoints and conditional breaks, watch expressions, stepping, and reading asynchronous call stacks.

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.

```js
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.

```js
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`.

```js
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 `await` is 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.
