Debugging Toolkit

Mental model: Debugging is not guessing faster. It is halving the distance between the last place the value was right and the first place it was wrong.

Level: intermediate · about 16 minutes

You already know one debugging tool. console.log is fine, and professionals use it every day. The problem is that it is the only tool most people learn, so every question becomes "add a log, rerun, read the wall of output, add another log". This lesson gives you the rest of the console, the pause-and-look tools, and a method that stops you from guessing.

The console is nine tools, not one

const rows = [
  { sku: 'tea', qty: 2, price: 3.5 },
  { sku: 'coffee', qty: 11, price: 7.25 },
  { sku: 'oat milk', qty: 1, price: 2 },
];

console.table(rows);
console.table(rows, ['sku', 'qty']); // only the columns you care about

Arrays of objects are a table. Stop reading them as a list.

console.group('processing order 42');
console.log('items: 3');
console.warn('coupon expired, ignoring');
console.groupEnd();

for (const ch of 'aabbbc') console.count('char ' + ch); // -> char a: 1, char a: 2, ...

console.time('sum');
let total = 0;
for (let i = 0; i < 100000; i += 1) total += i;
console.timeLog('sum', 'halfway marker');
console.timeEnd('sum');   // -> sum: 1.8ms (your number will differ)

Grouping, counting and timing, all without editing your data flow.

const config = { theme: 'dark', nested: { deep: { deeper: { value: 1 } } } };

console.log(config);                      // may collapse the deep parts
console.dir(config, { depth: null });     // full structure, no truncation

console.assert(1 + 1 === 2, 'arithmetic is fine');
console.assert([].length === 1, 'an empty array has no length 1'); // logs, does NOT throw

function inner() { console.trace('reached inner'); }
function outer() { inner(); }
outer();                                  // prints the call path that got here

dir shows structure, assert states an expectation, trace answers "who called this?".

console.assert(false, 'this failed');
console.log('still running');

console.assert only reports. It never throws and never stops execution, which is exactly why it is safe to leave in a hot path but useless as a guard. If you need the program to stop, throw.

CallUse it forNote
console.log/info/debuganythingdebug is hidden unless the level filter includes it
console.warn / errorthings you want to find laterboth capture a stack trace in browsers
console.table(rows, cols)arrays of objects, Map contentssortable columns in devtools
console.group / groupEndnesting a phase of workgroupCollapsed starts folded
console.count(label)"how many times does this run?"countReset(label) to start over
console.time / timeLog / timeEndrough durationsnot a benchmark, the resolution is coarse
console.dir(obj, { depth })structure of a deep objectin Node, the way to beat truncation
console.assert(cond, msg)stating an expectation inlinenever throws, never halts
console.trace(msg)"who called this?"the cheapest answer to that question

Stop reading output, start pausing time

A log answers one question you thought to ask. A breakpoint lets you ask every question at once: every variable in scope, the whole call stack, and what happens on the next line. The cost is starting the debugger. The payoff is that you stop rerunning.

function priceFor(item, coupons) {
  const applied = coupons.filter((c) => c.sku === item.sku);
  debugger; // pauses here when devtools are open, ignored otherwise
  return applied.reduce((price, c) => price - c.amount, item.price);
}

The debugger statement: a breakpoint that lives in the source.

In a browser, debugger does nothing until devtools are open. In Node, run node --inspect-brk app.js and open chrome://inspect, or press the run-and-debug button in your editor. Just remember debugger is a code change, which means it can be committed by accident. Prefer breakpoints set in the UI.

Breakpoint typeHow to set itWhen it earns its keep
Lineclick the gutter number in Sourcesyou know roughly where the problem is
Conditionalright click the gutter, add i === 47the bug only happens on one iteration
Logpointright click the gutter, choose Logpointyou want a log without editing (or committing) the file
On caught / uncaught exceptionthe pause-on-exceptions togglesomething throws and you cannot find the throw
Event listenerEvent Listener Breakpoints panel"what runs when I click this?"
DOM changeright click a node, Break on subtree changesomething rewrites your markup and you do not know what
Fetch / XHRXHR breakpoints, filter by URLa request fires with the wrong body
Functiondebug(myFn) in the consoleyou have the function but not its file

The three panes that answer everything

Call stack
who called this, in order. Click any frame to inspect that frame's variables
Scope
Local, Closure and Global values at this instant. The closure section is where captured state hides
Watch
expressions you add once, re-evaluated at every pause. Put the suspicious expression here
Step over (F10)
run the next line, do not descend into calls
Step into (F11)
descend into the call on this line
Step out (Shift+F11)
finish this function, pause in the caller
Resume (F8)
run until the next breakpoint
Ignore list
mark library files as ignored so stepping stays in your code
Restart frame
rerun the current function from the top, no page reload

Two habits pay off immediately. First, add the expression you are unsure about to Watch rather than logging it, because Watch updates on every pause for free. Second, turn on the ignore list (formerly "blackboxing") for node_modules so Step Into stops dumping you inside a framework you were not asking about.

Source maps

The code running in the browser is bundled, minified and often transpiled, so the line that throws is a.b.c on line 1 of main-4f3a.js. A source map is a JSON file that maps positions in the built file back to positions in your original source. With it loaded, devtools shows you your file, your names, and your line numbers, and breakpoints land where you meant.

  • The built file ends with a comment: //# sourceMappingURL=main-4f3a.js.map.
  • Devtools must have JavaScript source maps enabled (it is on by default, but people turn it off and forget).
  • A stale map is worse than none: breakpoints land a few lines off and you debug the wrong statement. Rebuild before you trust it.
  • Variable names need the map too. Without it, minified locals show as t, e, n.
  • In production, either ship maps and accept that your source is readable, or upload them to your error tracker only, and serve them behind authentication.

Network and performance, briefly

PanelThe question it answersThe control people miss
Networkwas the request sent, with what, and what came back?right click a row, Copy as fetch, then replay it in the console
Networkis it slow, or is it slow for users?throttling presets, plus Disable cache while devtools is open
Performancewhy did the interaction feel sluggish?record, then look for long tasks over 50ms in the flame chart
Performanceis it my code or layout and paint?the summary donut splits scripting, rendering and painting
Memorywhy does it get slower the longer it runs?two heap snapshots, then compare, then look at retainers
Coveragewhich of this bundle actually ran?record a session, read the unused bytes column

A method: bisect the problem

  1. 1. Reproduce it on demand If you cannot trigger it, you cannot tell whether you fixed it. Shrink the input, fix any randomness, write down the exact steps.
  2. 2. State the expectation precisely "It is broken" is not a bug report. "total should be 12.50 and is '1250'" tells you the type is wrong, not the arithmetic.
  3. 3. Halve the data Feed in half the rows. Still broken? Halve again. Ten thousand rows becomes the one bad row in about fourteen steps.
  4. 4. Halve the pipeline Find the midpoint of the transformation chain and check the value there. Right at the midpoint? The bug is downstream. Wrong? It is upstream.
  5. 5. Halve the history If it used to work, git bisect start, mark a good and a bad commit, and let git binary search the change for you.
  6. 6. Change one thing, then lock it in One change, verify, keep or revert. When it is fixed, write the test that would have caught it. That is the only part of debugging that compounds.

Shotgun logging

console.log('a', a);
console.log('b', b);
console.log('here');
console.log('here 2');
// rerun, read 400 lines,
// add four more logs,
// rerun again

Binary search

// value correct after parse()?
//   yes -> look downstream
//   no  -> look upstream
// one breakpoint at the midpoint,
// one question per pause,
// no reruns

Shotgun logging is linear in the size of the pipeline and needs a rerun per round. Bisection is logarithmic and often needs no rerun at all, because a single pause exposes the whole scope.

Try it yourself

Run every console method

const orders = [
  { id: 1, customer: 'ada', total: 42.5, status: 'paid' },
  { id: 2, customer: 'grace', total: 7, status: 'pending' },
  { id: 3, customer: 'alan', total: 128.25, status: 'paid' },
];

console.group('orders report');
console.table(orders, ['id', 'customer', 'total']);

const paid = orders.filter((o) => o.status === 'paid');
console.log('paid orders:', paid.length, 'of', orders.length);

console.time('total');
const revenue = paid.reduce((sum, o) => {
  console.count('reducer step');
  return sum + o.total;
}, 0);
console.timeEnd('total');

console.assert(revenue > 0, 'revenue should be positive');
console.log('revenue:', revenue.toFixed(2));
console.groupEnd();

Add a console.groupCollapsed around the table. Then use console.count to prove how many times the reducer callback runs.

Bisect a broken pipeline

const raw = ['12.50', '7.25', '3.00'];

const parse = (rows) => rows.map((r) => r.trim());
const toNumbers = (rows) => rows.map((r) => Number.parseInt(r, 10));
const scale = (nums) => nums.map((n) => n * 1);
const sum = (nums) => nums.reduce((a, b) => a + b, 0);

const stage1 = parse(raw);
const stage2 = toNumbers(stage1);
const stage3 = scale(stage2);
const total = sum(stage3);

console.log('expected 22.75, got', total);

// midpoint first: is stage2 already wrong?
console.table([{ stage: 'stage2', value: JSON.stringify(stage2) }]);

One of the four stages corrupts the total. Do not read the code first: check the value at the midpoint, then halve again. Find it in two questions.

Exercises

Parse a stack trace

Write parseStack(stack) turning a V8 style stack string into an array of frames, in order. Ignore the first message line and anything that is not a frame. A frame line looks like at readConfig (/app/src/config.js:12:11) and becomes { fn: 'readConfig', file: '/app/src/config.js', line: 12, column: 11 }. A frame with no function name, like at /app/src/index.js:9:1, gets fn: '(anonymous)'. line and column are numbers.

A logger you can assert on

Write createLogger() returning { log, warn, error, entries, lines, count }. The three level methods take any number of arguments and record { level, message } where message is the arguments joined with a single space. entries() returns a copy of the records. lines() returns strings like '[warn] disk almost full'. count(level) returns how many entries have that level, or the total when called with no argument.

Check yourself

What does this print?
x: 1, x: 2, y: 1 — console.count keeps a counter per label and prints the label with its running total every time. countReset(label) puts one back to zero. It answers "how many times did this run?" without you maintaining a variable.
What is the difference between a conditional breakpoint and a logpoint?
A conditional breakpoint pauses when an expression is true, a logpoint logs an expression and keeps going — Both are set from the gutter context menu. The conditional one stops so you can look around, the logpoint prints and resumes. The logpoint is a console.log you never have to add to, or remove from, the file.
Devtools shows your original source but breakpoints pause two lines away from where you clicked. What is the most likely cause?
A stale or incorrect source map — If maps were disabled you would be looking at minified code, not your source. Seeing your source with wrong positions is the signature of a map that no longer matches the built file. Rebuild, hard reload, and check the map file timestamp.
A bug appears somewhere in a chain of six transformations over 10,000 rows. What is the most efficient first move?
Check the value at the midpoint of the chain to find out which half is at fault — One observation at the midpoint eliminates half the pipeline, so six stages take about three checks. Logging everything is linear in the number of stages and gives you a wall of output to read on every rerun.

Common mistakes

  • Only ever using console.log, then reading four hundred lines of output by eye.
  • Trusting an expanded object in the browser console, which shows its current state and not the state at log time.
  • Treating console.assert as a guard. It never throws and never halts.
  • Leaving debugger statements or debug logs in a commit.
  • Stepping into library code because the ignore list was never turned on.
  • Debugging against a stale source map and concluding the wrong line is at fault.
  • Changing three things at once, then not knowing which one fixed it.

Takeaways

  • console is a toolkit: table for rows, count for frequency, group for phases, dir for structure, trace for callers.
  • console.assert reports and continues, so it can never replace a guard that throws.
  • A breakpoint answers every question about a moment. A log answers the one question you thought to ask.
  • Conditional breakpoints and logpoints are how you debug loops and hot paths without a rebuild.
  • Read the call stack for "how did I get here" and the Scope pane, especially Closure, for "what do I have".
  • Source maps make built code debuggable, and a stale map quietly points you at the wrong line.
  • Bisect the data, the pipeline, then the history. Change one thing at a time and end with a test.