How to buildOctober 11, 20267 min read
Programming FundamentalsStep 14 of 15

Debugging: breakpoints, console and reading a stack trace

In short

Debugging is testing hypotheses, not trying random edits: reproduce the bug, narrow down where it happens and confirm the cause with facts. A stack trace is read top-down: the first line is the error type and message, and the first at line pointing to your file is where the program failed. When nothing throws but the result is wrong, set a breakpoint and inspect variables at run time — in Chrome DevTools, VS Code or via node --inspect.

How-toLevel: BeginnerDebuggingPractice: ~30–60 min

After this you will be able to

  • Read a Node.js or browser stack trace down to the first line of your own code and name the file, line and function
  • Find a bug that throws nothing by using console.table and checking value types
  • Set a breakpoint and inspect the call stack and variables in Chrome DevTools, VS Code or node inspect
  • State a hypothesis about a bug's cause and confirm it before fixing

Verified with

Node.js 24 LTS (24.18)Chrome DevToolsVS Code

Checked against docs: October 11, 2026

Contents
  1. 01Debugging as a method: reproduce, narrow down, verify
  2. 02Bug 1: reading a stack trace top-down
  3. 03Bug 2: when nothing throws — console.log and console.table
  4. 04Bug 3: breakpoint, call stack and watch
  5. 05The same search in DevTools and VS Code
  6. 06Checklist before you say “it doesn't work”

Debugging as a method: reproduce, narrow down, verify

The most expensive beginner habit is changing code at random until the error goes away. Sometimes it does, but the cause stays and comes back elsewhere. Debugging is a short four-step loop that works in any language.

  1. Reproduce

    Find the minimal actions or data that trigger the bug every time. A bug you cannot reproduce, you cannot verify.

  2. Narrow down

    Find the file, function and line where values go wrong. Stack traces, console output and breakpoints help here.

  3. Form a hypothesis

    One testable sentence: “delivery arrives as a string, so + concatenates instead of adding”.

  4. Verify and fix

    Confirm the hypothesis with a fact — console output or a value in the debugger — and only then change the code. After the fix, go back to step 1.

Let's run the loop on a teaching program: it totals a coffee shop order with delivery and a promo code. It has three deliberate bugs of different kinds: a crash with an exception, a wrong result caused by types, and a condition that never fires.

cart.mjs
export function calcSubtotal(items) {
  let total = 0;
  for (let i = 0; i <= items.length; i++) {
    total += items[i].price * items[i].qty;
  }
  return total;
}

export function addDelivery(subtotal, delivery) {
  return subtotal + delivery;
}

export function applyPromo(total, code) {
  if (code === "sale") return Math.round(total * 0.9);
  return total;
}

export function checkout(cart) {
  const subtotal = calcSubtotal(cart.items);
  const withDelivery = addDelivery(subtotal, cart.delivery);
  return applyPromo(withDelivery, cart.promo);
}
Calculation module with three bugs — do not fix them in advance
main.mjs
import { checkout } from "./cart.mjs";

const cart = {
  items: [
    { name: "latte", price: 1500, qty: 2 },
    { name: "croissant", price: 1200, qty: 1 },
  ],
  delivery: "500",
  promo: "SALE",
};

console.log("Total:", checkout(cart));
Entry point: the expected total is (1500 × 2 + 1200 + 500) × 0.9 = 4230

Bug 1: reading a stack trace top-down

Run node main.mjs and it crashes. The output is real, from Node.js 24; the long folder path is replaced with …:

terminal
$ node main.mjs
file:///…/cart.mjs:4
    total += items[i].price * items[i].qty;
                      ^

TypeError: Cannot read properties of undefined (reading 'price')
    at calcSubtotal (file:///…/cart.mjs:4:23)
    at checkout (file:///…/cart.mjs:19:20)
    at file:///…/main.mjs:12:23
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
    at async node:internal/modules/esm/loader:643:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v24.18.0
Stack trace of an uncaught exception in Node.js 24

cart.mjs:4 and the line with ^

What it tells you
Node.js prints the source line and puts ^ under the failing spot

TypeError: Cannot read properties of undefined (reading 'price')

What it tells you
Type and message: something tried to read price from undefined

at calcSubtotal (…/cart.mjs:4:23)

What it tells you
The most recent call: function, file, line 4, column 23. Start here

at checkout (…/cart.mjs:19:20)

What it tells you
Who called calcSubtotal

at file:///…/main.mjs:12:23

What it tells you
Who called checkout: module top level, hence no function name

at ModuleJob.run (node:internal/…)

What it tells you
Node.js internals — usually skipped

According to the Node.js docs, the first line of a stack has the form “error class: message”, followed by frames, each starting with at. The most recent call is on top, the entry point at the bottom. The reading rule: find the first at line with your file, not node:internal or node_modules. That is where it broke, although the cause may sit further down the stack — in whoever passed the bad data.

Hypothesis: on some iteration items[i] is undefined. The array has two elements with indexes 0 and 1, but i <= items.length lets the loop reach index 2. This is the classic off-by-one error. The fix is a strict <.

Bug 2: when nothing throws — console.log and console.table

With the loop fixed, the program prints Total: 4200500. No exception, but the result is absurd. No stack trace will appear — you have to narrow it down yourself. The fastest tool is temporary output right before the suspicious spot. console.table shows an array of objects as a table, and typeof shows a value's type:

main.mjs
console.table(cart.items);
console.log("delivery:", cart.delivery, typeof cart.delivery);
Fragment: two lines added to main.mjs before the checkout call
terminal
$ node main.mjs
┌─────────┬─────────────┬───────┬─────┐
│ (index) │ name        │ price │ qty │
├─────────┼─────────────┼───────┼─────┤
│ 0       │ 'latte'     │ 1500  │ 2   │
│ 1       │ 'croissant' │ 1200  │ 1   │
└─────────┴─────────────┴───────┴─────┘
delivery: 500 string
Total: 4200500
Real output: prices are numbers, delivery is a string

Hypothesis confirmed: delivery is the string "500", and + concatenates 4200 and "500" into "4200500". Data like this often comes from forms, query parameters and JSON where a number is quoted. Fix it at the boundary — convert to a number where the value enters the calculation:

cart.mjs
--- a/cart.mjs
+++ b/cart.mjs
@@ -1,11 +1,11 @@
 export function calcSubtotal(items) {
   let total = 0;
-  for (let i = 0; i <= items.length; i++) {
+  for (let i = 0; i < items.length; i++) {
     total += items[i].price * items[i].qty;
   }
   return total;
 }

 export function addDelivery(subtotal, delivery) {
-  return subtotal + delivery;
+  return subtotal + Number(delivery);
 }
Fixes for bugs 1 and 2
terminal
$ node main.mjs
Total: 4700
Real output after two fixes: we expected 4230

Bug 3: breakpoint, call stack and watch

The total is 4700 — no discount. Nothing throws, all types are numbers. We need to pause inside applyPromo and see what happens. A breakpoint pauses execution before a line runs. The simplest way is the debugger; statement: it acts as a breakpoint when a debugger is attached and does nothing otherwise.

Add debugger; as the first line of applyPromo and run Node.js's built-in console debugger, node inspect. Below is a real session: cont continues to the next pause, bt prints the call stack, exec evaluates an expression, watch tracks a value, next steps to the next line.

terminal
$ node inspect main.mjs
< Debugger listening on ws://127.0.0.1:9229/31bee525-ecb9-4a6c-b9ea-f75545b276bf
< For help, see: https://nodejs.org/learn/getting-started/debugging
< 
connecting to 127.0.0.1:9229 ... ok
< Debugger attached.
< 
debug> Break on start in cart.mjs:1
> 1 export function calcSubtotal(items) {
  2   let total = 0;
  3   for (let i = 0; i < items.length; i++) {
debug> cont
debug> break in cart.mjs:14
 12 
 13 export function applyPromo(total, code) {
>14   debugger;
 15   if (code === "sale") return Math.round(total * 0.9);
 16   return total;
debug> bt
#0 applyPromo cart.mjs:14:2
#1 checkout cart.mjs:22:9
#2 (anonymous) main.mjs:12:22
#3 run node:internal/modules/esm/module_job:439:24
debug> exec code
'SALE'
debug> exec code === "sale"
false
debug> watch('total')
debug> next
debug> step in cart.mjs:15
Watchers:
  0: total = 4700

 13 export function applyPromo(total, code) {
 14   debugger;
>15   if (code === "sale") return Math.round(total * 0.9);
 16   return total;
 17 }
debug> cont
debug> < Total: 4700
< 
debug> < Waiting for the debugger to disconnect...
< 
debug> .exit
Real node inspect session: the debugger first pauses on line 1, then on debugger

Facts from the debugger: code is 'SALE', and comparing it with "sale" gives false — the case differs. bt shows the same chain as the stack trace: applyPromo ← checkout ← main.mjs. Fix the comparison and remove debugger;:

cart.mjs
-  if (code === "sale") return Math.round(total * 0.9);
+  if (code?.trim().toLowerCase() === "sale") return Math.round(total * 0.9);
Fix for bug 3: the promo code is compared ignoring whitespace and case
terminal
$ node main.mjs
Total: 4230
Real output after all three fixes

The same search in DevTools and VS Code

The console debugger is useful for understanding the mechanics, but day to day a graphical one is more convenient. Underneath it is the same thing: Node.js started with --inspect opens a port for a debugger, and Chrome DevTools or VS Code connects to it. The default is 127.0.0.1:9229. --inspect-brk also pauses before the first line of your code so you have time to set breakpoints:

terminal
$ node --inspect-brk main.mjs
Debugger listening on ws://127.0.0.1:9229/5f699728-1ccb-4e05-ba8a-2afa59a35332
For help, see: https://nodejs.org/learn/getting-started/debugging
Real output: the process waits for a debugger to attach

Attach to Node.js

Chrome DevTools
Open chrome://inspect (edge://inspect in Edge); the process shows up under Remote Target
VS Code
JavaScript Debug Terminal: any node run in it is debugged automatically

Debug page code

Chrome DevTools
Sources panel, the script file
VS Code
Through a browser launch configuration in launch.json

Set a breakpoint

Chrome DevTools
Click the line number in Sources
VS Code
Click left of the line number or press F9

Conditional pause

Chrome DevTools
Right-click the line number → Add conditional breakpoint
VS Code
Right-click the margin → Add Conditional Breakpoint

Log without pausing

Chrome DevTools
Logpoint: a Console message, no pause
VS Code
Logpoint: expressions in braces, e.g. {code}

Stepping

Chrome DevTools
Resume (F8), step through a statement (F9), Step over / into / out via buttons
VS Code
Continue F5, Step Over F10, Step Into F11, Step Out Shift+F11

Inspect values

Chrome DevTools
Scope, Watch and Call Stack panes
VS Code
Variables, Watch and Call Stack sections
  • Pause on exceptions: DevTools can pause on caught and uncaught exceptions — the debugger stops where the error is thrown, with all variables available.
  • Restart frame in DevTools re-runs the current function without restarting the program — handy for stepping through it again after editing a value in Scope.
  • A conditional breakpoint like i === items.length in the bug 1 loop pauses exactly on the faulty iteration instead of every one.

Checklist before you say “it doesn't work”

If a web app bug involves data from a server, start with the Network panel in DevTools: the status code and the request and response bodies often show right away whose side the problem is on. Requests, statuses and JSON are covered in detail in the “HTTP and JSON” lesson.

  • The bug is reproducible, and you have written down the exact steps or data
  • You read the full error message, not just its type
  • You found the first stack trace line with your file: file, line, function
  • You checked the types and values at the input of the suspicious function
  • For an API call, you checked the method, status code and response body in Network
  • The hypothesis was confirmed with a fact before the fix
  • Temporary console.log and debugger lines are removed
  • Your message to a colleague has steps, expected and actual result, and the stack trace
How this was verified7 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.