Debugging JavaScript

Beginner35 min

Find bugs with console output, stack traces, the debugger statement and the Node inspector.

Builds on
What you will learn
  • Read a stack trace from the top
  • Use console methods to inspect values while a program runs
  • Pause a program with the debugger statement and the Node inspector
  • Work through a bug by writing a hypothesis and testing it
On this page

A bug is a mismatch between what you expected and what happened. The fundamentals lesson on debugging describes the method: reproduce, form a hypothesis, test it, fix, and check. This lesson shows the JavaScript tools for each step.

Read the error first

When a program stops with an uncaught error, Node prints the location, the message and a stack trace. A stack trace lists the calls that were active when the error was created, innermost first.

The block below is what Node 26 prints for a small program in a made-up project folder, /home/ada/shop/total.mjs. In it, outer and inner are functions, which the next module teaches. All you need now is that outer called inner, and inner threw.

file:///home/ada/shop/total.mjs:2
throw new Error("something broke");
^
Error: something broke
at inner (file:///home/ada/shop/total.mjs:2:9)
at outer (file:///home/ada/shop/total.mjs:5:3)
at file:///home/ada/shop/total.mjs:7:1
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:105:5)
Node.js v26.11.1

Read it from the top. The first lines say what happened and where. The at lines then say who called whom. The top at line is where the error was raised, the next is the caller, and so on, down to the start of the program. Each ends in file:line:column, which most terminals and editors let you click. Start at the top at line that is in your code. Lines inside node: modules are Node’s internals.

You can also reach the trace from code. The stack property is a string whose first line is name: message, followed by the at lines.

stack_first_line.mjs
try {
null.length;
} catch (error) {
const lines = error.stack.split("\n");
console.log(lines[0]);
console.log(lines[1].trim().startsWith("at "));
}
Output
TypeError: Cannot read properties of null (reading 'length')
true

split("\n") cuts the text into lines, and lines[0] is the first one. The stack text is not standardised in detail and differs between engines, so use it for reading, not for parsing.

Looking at values with console

The quickest debugging tool is printing. The aim is to see a value at the point where you suspect it goes wrong.

debug_logging.mjs
const orders = [
{ id: 1, item: "pen", qty: 3, price: 1.5 },
{ id: 2, item: "book", qty: 1, price: 12 },
];
let total = 0;
for (const order of orders) {
console.log("order:", order);
total += order.qty * order.price;
}
console.log({ total });
console.table(orders);
console.group("details");
console.log("inside the group");
console.groupEnd();
console.count("check");
console.count("check");
Output
order: { id: 1, item: 'pen', qty: 3, price: 1.5 }
order: { id: 2, item: 'book', qty: 1, price: 12 }
{ total: 16.5 }
┌─────────┬────┬────────┬─────┬───────┐
│ (index) │ id │ item │ qty │ price │
├─────────┼────┼────────┼─────┼───────┤
│ 0 │ 1 │ 'pen' │ 3 │ 1.5 │
│ 1 │ 2 │ 'book' │ 1 │ 12 │
└─────────┴────┴────────┴─────┴───────┘
details
inside the group
check: 1
check: 2
  • console.log("order:", order) puts a label in front of the value. Labelled output is far easier to scan than a column of bare numbers.
  • console.log({ total }) is a shortcut: the braces create an object whose key is the variable name, so the output shows { total: 16.5 } and you do not need to type the label.
  • console.table lays an array of objects out as a table.
  • console.group indents the output until console.groupEnd. console.count counts how many times a line was reached, which answers “does this code even run?”
  • console.assert(condition, ...message) prints a message only when the condition is false. It goes to the error stream, as do console.error and console.warn, so you can separate those from normal output.

Remove or silence the printing when you are done. Debugging output that stays in the code becomes noise.

Pausing with debugger

Printing shows you values at the points you chose. A debugger lets you stop the program and look at everything, then step forward one line at a time.

The statement debugger; marks a place to pause. It does nothing unless a debugger is attached.

debugger_stmt.mjs
const numbers = [2, 4, 9];
let sum = 0;
for (const n of numbers) {
sum += n;
}
debugger; // pauses here only when a debugger is attached
console.log(sum / numbers.length);
Output
5

Run normally, the program just prints the average, 5. Run under the inspector, it stops on the debugger line. Node offers two ways to start it.

  1. node --inspect-brk debugger_stmt.mjs starts the program, waits for a debugger client, and pauses on the first line. Open chrome://inspect in Chrome (edge://inspect in Edge) and click “inspect” under your Node process. (--inspect starts the inspector without pausing at the first line, which is what you want for a program that keeps running.)
  2. In VS Code, use the Run and Debug panel to start the file under its built-in Node debugger. A click in the margin next to a line number sets a breakpoint, which pauses at that line without editing the code. Chrome DevTools offers breakpoints the same way.

There is also a plain command-line debugger: node inspect debugger_stmt.mjs.

Once paused you can:

  • read the current value of any variable in scope,
  • step over a line, step into a function call, or step out of the current function,
  • watch an expression as it changes,
  • see the call stack, the same list as in a stack trace but live, and click a caller to see its variables.

The browser’s DevTools offer the same features for code running in a page, under the “Sources” panel.

A debugging routine

When something is wrong:

  1. Reproduce it with the smallest input you can.
  2. Read the error if there is one, and go to the first line in your code.
  3. Write a hypothesis in one sentence, such as “the loop runs one time too many”. It must be something you can test.
  4. Test it with a print or a breakpoint. Do not change code yet.
  5. Fix one thing, run again, and check that the symptom is gone and nothing else broke.

Writing the hypothesis down matters. It stops you changing code at random and gives you something to cross out when you are wrong.

Common pitfalls

  • Changing code before understanding the bug. Several guesses at once leave you unsure which one worked.
  • Ignoring the first line of the error. The message usually names the exact problem.
  • Logging an object that you later change. The console may show the object as it is when you expand it, not when it was printed. Log a copy, or a specific field.
  • Leaving debugger; and console.log in finished code. Remove them or use a proper logging tool.
  • Debugging the wrong file. Check that the code you edit is the code that runs.

Exercises

ExerciseFix four seeded bugs

Four small programs follow, each with one bug. Run each one. For each wrong output write down a hypothesis, test it with console.log or a breakpoint, and then fix the code.

Bug 1. This should print the average of the numbers.

const numbers = [2, 4, 9];
let sum = 0;
for (let i = 0; i <= numbers.length; i++) {
sum += numbers[i];
}
console.log(sum / numbers.length);

Bug 2. The three form fields are text. This should print their sum.

const fields = ["10", "20", "30"];
let total = 0;
for (const field of fields) {
total = total + field;
}
console.log(total);

Bug 3. This should print empty only for an empty list. The list holds two items.

const list = [1, 2];
if (list.length = 0) {
console.log("empty");
} else {
console.log("not empty");
}
console.log(list);

Bug 4. This should print the price doubled.

const price = 4;
price * 2;
console.log(price);

Input: none. The buggy programs print NaN, then 0102030, then not empty followed by [], then 4. Expected output after the fixes: 5, 60, not empty followed by [ 1, 2 ], and 8.

Show solution
  1. The average prints NaN. Hypothesis: the loop reads past the end of the array, because <= runs one pass too many and numbers[3] is undefined. Adding undefined to a number gives NaN. A console.log(i, numbers[i]) inside the loop confirms it. Fix: use <.
seeded_fixed_1.mjs
const numbers = [2, 4, 9];
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
console.log(sum / numbers.length);
Output
5
  1. The total prints 0102030. Hypothesis: the form fields are strings, so + joins text instead of adding. The coercion lesson covers this. Fix: convert with Number(field).
seeded_fixed_2.mjs
const fields = ["10", "20", "30"];
let total = 0;
for (const field of fields) {
total = total + Number(field);
}
console.log(total);
Output
60
  1. The list is reported as not empty, but it is now []. Hypothesis: list.length = 0 assigns instead of comparing, which clears the list and gives a falsy result. The second value printed, [], gives it away. Fix: list.length === 0.
seeded_fixed_3.mjs
const list = [1, 2];
if (list.length === 0) {
console.log("empty");
} else {
console.log("not empty");
}
console.log(list);
Output
not empty
[ 1, 2 ]
  1. The price prints 4. Hypothesis: price * 2; computes a value and throws it away, because nothing stores or prints it. Fix: store the result and print that.
seeded_fixed_4.mjs
const price = 4;
const doubled = price * 2;
console.log(doubled);
Output
8
ExerciseTrace a running total

Given const prices = [3, 8, 5];, use a for...of loop and console.log to show the running total after each price, in the form price: 3 total: 3. After the loop, print the final total with the { total } shortcut.

Input: none. Expected output: price: 3 total: 3, price: 8 total: 11, price: 5 total: 16, { total: 16 }.

Show solution
trace_total.mjs
const prices = [3, 8, 5];
let total = 0;
for (const price of prices) {
total += price;
console.log("price:", price, "total:", total);
}
console.log({ total });
Output
price: 3 total: 3
price: 8 total: 11
price: 5 total: 16
{ total: 16 }

Printing inside the loop shows every step, so a wrong value would show up at the pass where it first appears. The { total } line labels the final value with its variable name.

ExerciseConfirm a hypothesis

This program should print every item, but it skips the first.

const items = ["a", "b", "c"];
for (let i = 1; i < items.length; i++) {
console.log(items[i]);
}

Write your hypothesis in one sentence. Test it by printing i next to each item, then fix the program.

Input: none. The buggy program prints b and c. Expected output after the fix: a, b, c.

Show solution

Hypothesis: the counter starts at 1, but the first item sits at position 0. Printing i next to each item shows the loop starts at 1.

skipped_item.mjs
const items = ["a", "b", "c"];
for (let i = 0; i < items.length; i++) {
console.log(items[i]);
}
Output
a
b
c

Counting starts at zero, so the loop must too. Writing the hypothesis first meant one change, not a guess at the loop condition.

Summary

  • Read errors from the top: the message first, then the first at line in your own code.
  • Label what you print, and use console.table, console.group and console.count to see more at once.
  • debugger; and breakpoints pause the program. Start Node with node --inspect-brk file.mjs and attach Chrome DevTools or VS Code to step through it.
  • Debug with a routine: reproduce, hypothesise, test, fix one thing, check.

Next module: functions, starting with the function forms lesson.