Debugging and Developer Tools
Finding bugs in JavaScript: console methods beyond log, browser developer tools with breakpoints and the network panel, debugging Node.js, and a calm method for hard bugs.
Start with the error message
Most bugs announce themselves. When JavaScript cannot continue, it throws an error, and if nothing catches it, Node.js or the browser console prints it. The general skill of reading such reports is described in reading errors, logs and documentation; here is what it looks like in JavaScript. This script adds up meter readings from Kestrel Energy, but one entry in the batch is missing:
function parseReading(line) {
const [meter, kwh] = line.split(",");
return { meter, kwh: Number(kwh) };
}
function totalKwh(lines) {
let total = 0;
for (const line of lines) {
total += parseReading(line).kwh;
}
return total;
}
const batch = ["KE-1042,12.5", "KE-1043,0", undefined];
console.log("Total:", totalKwh(batch));
TypeError: Cannot read properties of undefined (reading 'split')
The output above shows only the final line. In a terminal, Node.js prints the full report on standard error:
/home/sam/kestrel/report.js:2
const [meter, kwh] = line.split(",");
^
TypeError: Cannot read properties of undefined (reading 'split')
at parseReading (/home/sam/kestrel/report.js:2:29)
at totalKwh (/home/sam/kestrel/report.js:9:14)
at Object.<anonymous> (/home/sam/kestrel/report.js:15:23)
at Module._compile (node:internal/modules/cjs/loader:1521:14)
...
Node.js v20.20.0
Read it from the top:
-
Where it broke: file, line 2, and a caret
(
^) under the exact spot. -
What broke: a
TypeError. The message says the code tried to read the propertysplitofundefined, solinewasundefined. -
How it got there: the stack trace lists the
call stack, newest
call first.
parseReadingwas called bytotalKwh(line 9), which was called from the top level of the file (line 15). Lines starting withnode:internalbelong to Node.js itself; skip them.
Reading those three parts usually takes you to the right line before you need any tool. The error is where the program noticed the problem; the cause is often earlier — here, in the data on line 14.
Console methods beyond log
Printing values is the oldest debugging technique, and it still
works (see
print debugging). JavaScript's console object, available in every
browser and in Node.js, has methods that make the printed evidence
much easier to read.
Log with context
A bare console.log(total) prints a number without
saying which variable it is. Wrap the values in an object literal
instead: the property names come along for free. Adding
typeof exposes a whole class of bugs. This script reads
the meter readings as text, and the total comes out wrong:
const csv = "KE-1042,12.5\nKE-1043,0\nKE-1044,9.75";
let total = 0;
for (const line of csv.split("\n")) {
const [meter, kwh] = line.split(",");
console.log({ meter, kwh, type: typeof kwh, total });
total += kwh;
}
console.log("Total kWh:", total);
{ meter: 'KE-1042', kwh: '12.5', type: 'string', total: 0 }
{ meter: 'KE-1043', kwh: '0', type: 'string', total: '012.5' }
{ meter: 'KE-1044', kwh: '9.75', type: 'string', total: '012.50' }
Total kWh: 012.509.75
The quotes around '12.5' and
type: 'string' give it away:
split() returns strings, and + with a
string concatenates instead of adding (see
type coercion). Converting each value with the Number()
function fixes it:
const csv = "KE-1042,12.5\nKE-1043,0\nKE-1044,9.75";
let total = 0;
for (const line of csv.split("\n")) {
const [meter, kwh] = line.split(",");
total += Number(kwh);
}
console.log("Total kWh:", total);
Total kWh: 22.25
Tables of data
console.table()
prints an array of objects (or an object of objects) as a table, one
row per item and one column per property. A second argument picks
the columns. For a list of records it is far easier to scan than a
nested printout:
const readings = [
{ meter: "KE-1042", kwh: 12.5, status: "ok" },
{ meter: "KE-1043", kwh: 0, status: "no data" },
{ meter: "KE-1044", kwh: 9.75, status: "ok" },
];
console.table(readings);
console.table(readings, ["meter", "kwh"]);
┌─────────┬───────────┬──────┬───────────┐ │ (index) │ meter │ kwh │ status │ ├─────────┼───────────┼──────┼───────────┤ │ 0 │ 'KE-1042' │ 12.5 │ 'ok' │ │ 1 │ 'KE-1043' │ 0 │ 'no data' │ │ 2 │ 'KE-1044' │ 9.75 │ 'ok' │ └─────────┴───────────┴──────┴───────────┘ ┌─────────┬───────────┬──────┐ │ (index) │ meter │ kwh │ ├─────────┼───────────┼──────┤ │ 0 │ 'KE-1042' │ 12.5 │ │ 1 │ 'KE-1043' │ 0 │ │ 2 │ 'KE-1044' │ 9.75 │ └─────────┴───────────┴──────┘
In a browser the same call draws an interactive table whose columns you can sort by clicking the header.
Deeply nested objects
Node.js shortens nested data: beyond two levels deep it prints
[Array] or [Object] instead of the
contents. console.dir() with the
depth option shows everything (null means
no limit):
const ticket = {
id: "INC-31902",
site: { name: "Den Haag", feeder: { id: "F7", meters: [1042, 1043] } },
};
console.log(ticket);
console.dir(ticket, { depth: null });
{
id: 'INC-31902',
site: { name: 'Den Haag', feeder: { id: 'F7', meters: [Array] } }
}
{
id: 'INC-31902',
site: { name: 'Den Haag', feeder: { id: 'F7', meters: [ 1042, 1043 ] } }
}
Browsers do not shorten objects; they show them as a tree you can
expand. Be aware that the browser console shows an object's
current contents when you expand it, not the contents at
the moment it was logged. If the object changed in between, you see
the new values. Log a copy (structuredClone(obj)) or
JSON.stringify(obj) when the timing matters.
Grouping and counting
console.group() indents everything until the matching
console.groupEnd(), which keeps the output of nested
steps readable. console.count(label) prints how many
times it has been called with that label, which answers "how often
does this code run?" without a counter variable of your own.
const results = { "KE-1042": "ok", "KE-1043": "timeout", "KE-1044": "ok" };
console.group("Batch 2026-10-01");
for (const [meter, status] of Object.entries(results)) {
console.log(meter, status);
if (status === "timeout") {
console.group("Retry");
console.log(meter, "ok after 1 retry");
console.groupEnd();
}
console.count(status);
}
console.groupEnd();
console.log("done");
Batch 2026-10-01
KE-1042 ok
ok: 1
KE-1043 timeout
Retry
KE-1043 ok after 1 retry
timeout: 1
KE-1044 ok
ok: 2
done
Timing
console.time(label) starts a stopwatch and
console.timeEnd(label) stops it and prints the elapsed
milliseconds; console.timeLog(label) prints an
intermediate time. Use it to find out which part of a program is
slow before you try to speed anything up. The numbers differ on
every run, so this example is not run by our checker:
console.time("parse");
const lines = csv.split("\n").map(parseReading);
console.timeEnd("parse");
parse: 0.412ms
For measurements inside your own code,
performance.now() returns a high-resolution timestamp
in milliseconds; subtract two of them to get a duration. One
measurement says little: repeat it and look at the typical value.
Warnings, errors and traces
console.warn() and console.error() print
like log, but browsers color them and let you filter
the console by level. console.trace() prints a stack
trace at that point: handy when you want to know who called
a function. console.assert(condition, message) prints
"Assertion failed" only when the condition is false, and does not
stop the program.
In Node.js these four write to standard error, not
standard output. That matters when you redirect output: with
node check-readings.js > readings.txt the warnings
still appear in the terminal and do not end up in the file (see
pipes and redirection).
node check-readings.js > readings.txt
Assertion failed: meter KE-1043 has no reading
Browser developer tools
Every desktop browser includes browser developer tools (DevTools): open them with F12 or Ctrl+Shift+I (Cmd+Option+I on a Mac). The panels that matter most for JavaScript:
| Panel | Use it for |
|---|---|
| Console |
Errors and warnings from the page, your
console output, and a prompt where you can run
JavaScript in the context of the page
|
| Sources (Debugger in Firefox) | The page's scripts, breakpoints, stepping, variables and the call stack |
| Elements (Inspector in Firefox) | The live DOM as your script changed it, and the styles that apply |
| Network |
Every request the page makes, including your
fetch() calls
|
| Application (Storage in Firefox) | Cookies, local storage and session storage |
| Performance | A recording of what the browser did over time, to find slow code |
The console prompt is a
REPL attached to the
page: you can read variables, call functions and try a fix before
changing the file. In Chrome and Edge, $0 refers to the
element currently selected in the Elements panel.
Breakpoints
A breakpoint pauses the program at a line, before that line runs. In the Sources panel, open the script and click the line number. When the code reaches that line, everything stops, and you can look around at leisure. DevTools offer several kinds:
- Line breakpoint: pause every time this line is reached.
-
Conditional breakpoint (right-click the line
number): pause only when an expression is true, such as
meter === "KE-1043". Essential in a loop that runs a thousand times and goes wrong once. -
Logpoint: print a message to the console without
pausing and without editing the code. A
console.logthat you never forget to remove. - Pause on exceptions: stop at the moment an error is thrown, with all variables still intact, instead of reading about it afterward in a stack trace.
- Event listener, DOM and fetch breakpoints: pause when a click handler runs, when a script changes a particular element, or when a request to a given URL starts. Useful when you do not know which code is responsible.
Stepping through code
While paused, you move through the program one step at a time. The buttons and keys are the same in Chrome, Edge, Firefox and VS Code:
| Command | Key | What it does |
|---|---|---|
| Resume | F8 (F5 in VS Code) | Run on until the next breakpoint |
| Step over | F10 | Run the current line, including any function it calls, and pause on the next line |
| Step into | F11 | If the line calls a function, go into it and pause on its first line |
| Step out | Shift+F11 | Run the rest of the current function and pause back in the caller |
Inspecting variables
While the program is paused, the side panel of the Sources tab shows its state:
- Scope: every variable that is visible at this point, grouped as Local, Closure and Global (see functions for scope and closures). You can even change a value and continue, to test a theory.
- Call stack: the chain of function calls that led here. Click a lower entry to see the variables of the caller as they were at the moment of the call.
- Watch: expressions you want to follow.
A watch expression is an
expression you add to the Watch pane, such as total,
typeof kwh or readings.length. The
debugger evaluates it again every time the program pauses, so you
see how it changes from step to step. Hovering over a variable in
the code shows its current value too.
The debugger statement
The
debugger statement
is a breakpoint written in the code. When developer tools are open
(or a debugger is attached to Node.js), execution pauses on that
line exactly as if you had clicked a breakpoint there. When no
debugger is attached, the statement does nothing:
function applyTariff(kwh, pricePerKwh) {
const cost = kwh * pricePerKwh;
debugger; // pauses here only when a debugger is attached
return Math.round(cost * 100) / 100;
}
console.log(applyTariff(12.5, 0.27));
3.38
It is useful when a breakpoint is hard to set by clicking: code
generated at run time, a file that a build tool rewrites, or a line
in a script that runs before you can open DevTools. Like a stray
console.log, it does not belong in committed code; the
ESLint rule no-debugger catches it.
The network panel for JavaScript
When a script talks to an API, the Network panel shows what really went over the wire. How to use it for HTTP problems in general, and how to export a HAR file safely, is described in testing and debugging APIs. A few points are specific to JavaScript:
- Filter on Fetch/XHR to see only the requests your code made, not images and style sheets.
- The Initiator column shows which script and line started the request. Click it to jump to that line in the Sources panel.
-
A
fetch()call that receives a 404 or 500 does not throw an error; the request is listed in red, but your code only notices if it checksresponse.ok(see fetching data from APIs). - A request blocked by CORS shows up as an error in the Console, but your script only sees a generic network error. The real reason is in the console message, never in the response.
- Throttling (for example "Slow 4G") makes requests slow on purpose, which exposes bugs that only appear when an answer arrives late, such as a spinner that never stops.
Source maps
The JavaScript a browser runs is often not the code you wrote. Build
tools combine many files into one bundle, minify it (short variable
names, no spaces, everything on one line) and sometimes translate it
from TypeScript. An error in such a bundle reports a position like
app.min.js:1:48213, which is useless to a human.
A source map is a JSON file that maps every position in the generated code back to the original file, line and column (and the original names). The generated file points to it with a comment on its last line:
//# sourceMappingURL=app.min.js.map
{
"version": 3,
"file": "app.min.js",
"sources": ["src/readings.js", "src/report.js"],
"names": ["parseReading", "totalKwh", "line"],
"mappings": "AAAA,SAASA,aAAaC..."
}
DevTools read the map automatically: the Sources panel shows your
original files, breakpoints go on original lines, and stack traces
name src/report.js:9 instead of a position in the
bundle. The mappings field is a compact encoding that
no human reads; the build tool writes it (the value above is
shortened). Node.js uses source maps for stack traces when you start
it with node --enable-source-maps.
Debugging Node.js
Node.js scripts can be debugged with the same tools. The
Node.js inspector is Node's
built-in debugging interface: started with the
--inspect option, Node.js opens a debugging connection
(by default on 127.0.0.1, port 9229) that Chrome DevTools, VS Code
and other debuggers can attach to.
node --inspect-brk report.js
Debugger listening on ws://127.0.0.1:9229/5bcc5785-2e25-483d-8c11-c3f272f001d8 For help, see: https://nodejs.org/en/docs/inspector
-
--inspectstarts the script and lets a debugger attach while it runs;--inspect-brkalso pauses before the first line, so a short script cannot finish before you have set your breakpoints. -
In Chrome or Edge, open
chrome://inspect(oredge://inspect) and click inspect under the script: you get the familiar Sources panel, with breakpoints, stepping and the console. -
In VS Code, a JavaScript Debug Terminal attaches
automatically to every
nodecommand you run in it, so breakpoints set in the editor just work.
Without a graphical tool, node inspect starts a simple
debugger in the terminal. sb(9) sets a breakpoint on
line 9, c continues, n steps over,
s steps into, o steps out,
exec evaluates an expression and bt prints
the call stack. With the script from the
first section:
node inspect report.js
debug> sb(9)
debug> c
break in report.js:9
7 let total = 0;
8 for (const line of lines) {
> 9 total += parseReading(line).kwh;
10 }
11 return total;
debug> exec line
'KE-1042,12.5'
debug> exec total
0
debug> c
break in report.js:9
debug> exec line
'KE-1043,0'
debug> bt
#0 totalKwh report.js:9:4
#1 (anonymous) report.js:15:22
A calm method for hard bugs
Tools help, but method finds bugs. The general approach is described in a debugging method and, for problems beyond code, in a structured troubleshooting method. Applied to JavaScript:
- Reproduce it. Find the exact steps, data and browser that trigger the bug. A bug you can trigger on demand is half solved; one that happens "sometimes" usually depends on timing, cached data or input you have not yet identified.
- Read the evidence. The full error, the console (all levels, not only errors) and the network panel. Check the first error, not the last.
-
Narrow it down. Find the smallest input and the
smallest piece of code that still fails. Halve the problem each
time: is the data already wrong when it arrives from the API, or
does your code change it? If it worked last week,
git bisectfinds the commit that broke it. -
Form one hypothesis and test it. "The total is
wrong because
kwhis a string" can be checked with one watch expression. Change one thing at a time, or you will not know which change helped. - Fix the cause, then keep a test. Write a test that fails without your fix (see testing), so the bug cannot quietly come back.
Usual suspects in JavaScript
When the cause is not obvious, check these first:
-
A value of the wrong type: a number that is
really a string (from a form, a URL or
split()), orundefinedwhere you expected an object.typeofin a log line or watch expression settles it. -
Timing: code that uses a result before an
asynchronous call has delivered it, such as a missing
await(see asynchronous JavaScript). The log showsPromise { <pending> }instead of data. -
A lost
this: a method passed as a callback no longer knows its object (see classes, prototypes and this). -
Loose comparison:
==where===was meant. - Stale code: the browser runs a cached old version of your script. Reload with the cache disabled (DevTools, Network panel, "Disable cache") before you doubt your fix.