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.
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
Know beforehand
Verified with
Node.js 24 LTS (24.18)Chrome DevToolsVS Code
Checked against docs: October 11, 2026
Contents
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.
Reproduce
Find the minimal actions or data that trigger the bug every time. A bug you cannot reproduce, you cannot verify.
Narrow down
Find the file, function and line where values go wrong. Stack traces, console output and breakpoints help here.
Form a hypothesis
One testable sentence: “delivery arrives as a string, so + concatenates instead of adding”.
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.
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);
}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));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 …:
$ 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.0cart.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
pricefromundefined
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:
console.table(cart.items);
console.log("delivery:", cart.delivery, typeof cart.delivery);$ node main.mjs
┌─────────┬─────────────┬───────┬─────┐
│ (index) │ name │ price │ qty │
├─────────┼─────────────┼───────┼─────┤
│ 0 │ 'latte' │ 1500 │ 2 │
│ 1 │ 'croissant' │ 1200 │ 1 │
└─────────┴─────────────┴───────┴─────┘
delivery: 500 string
Total: 4200500Hypothesis 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:
--- 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);
}$ node main.mjs
Total: 4700Bug 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.
$ 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> .exitFacts 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;:
- if (code === "sale") return Math.round(total * 0.9);
+ if (code?.trim().toLowerCase() === "sale") return Math.round(total * 0.9);$ node main.mjs
Total: 4230The 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:
$ 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/debuggingAttach to Node.js
- Chrome DevTools
- Open
chrome://inspect(edge://inspectin Edge); the process shows up under Remote Target - VS Code
- JavaScript Debug Terminal: any
noderun 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.lengthin 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.