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 property split of undefined, so line was undefined.
  • How it got there: the stack trace lists the call stack, newest call first. parseReading was called by totalKwh (line 9), which was called from the top level of the file (line 15). Lines starting with node:internal belong 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.log that 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
Stepping commands. The program is paused on the line const reading = parseReading(line) inside printReport. Step over runs that line completely and pauses on the next line of printReport. Step into goes into parseReading and pauses on its first line. Step out runs the rest of parseReading and pauses back in printReport. function printReport(line) const reading = parseReading(line); console.log(reading.meter, reading.kwh); function parseReading(line) const [meter, kwh] = line.split(","); return { meter, kwh: Number(kwh) }; Step over Step into Step out
The three stepping commands, seen from a program paused on the highlighted line. Step over treats the call as one step; step into follows it; step out finishes the function you are in.

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 checks response.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
  • --inspect starts the script and lets a debugger attach while it runs; --inspect-brk also pauses before the first line, so a short script cannot finish before you have set your breakpoints.
  • In Chrome or Edge, open chrome://inspect (or edge://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 node command 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:

  1. 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.
  2. Read the evidence. The full error, the console (all levels, not only errors) and the network panel. Check the first error, not the last.
  3. 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 bisect finds the commit that broke it.
  4. Form one hypothesis and test it. "The total is wrong because kwh is a string" can be checked with one watch expression. Change one thing at a time, or you will not know which change helped.
  5. 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()), or undefined where you expected an object. typeof in 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 shows Promise { <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.