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 aboutArrays 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 heredir 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.
| Call | Use it for | Note |
|---|---|---|
console.log/info/debug | anything | debug is hidden unless the level filter includes it |
console.warn / error | things you want to find later | both capture a stack trace in browsers |
console.table(rows, cols) | arrays of objects, Map contents | sortable columns in devtools |
console.group / groupEnd | nesting a phase of work | groupCollapsed starts folded |
console.count(label) | "how many times does this run?" | countReset(label) to start over |
console.time / timeLog / timeEnd | rough durations | not a benchmark, the resolution is coarse |
console.dir(obj, { depth }) | structure of a deep object | in Node, the way to beat truncation |
console.assert(cond, msg) | stating an expectation inline | never 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 type | How to set it | When it earns its keep |
|---|---|---|
| Line | click the gutter number in Sources | you know roughly where the problem is |
| Conditional | right click the gutter, add i === 47 | the bug only happens on one iteration |
| Logpoint | right click the gutter, choose Logpoint | you want a log without editing (or committing) the file |
| On caught / uncaught exception | the pause-on-exceptions toggle | something throws and you cannot find the throw |
| Event listener | Event Listener Breakpoints panel | "what runs when I click this?" |
| DOM change | right click a node, Break on subtree change | something rewrites your markup and you do not know what |
| Fetch / XHR | XHR breakpoints, filter by URL | a request fires with the wrong body |
| Function | debug(myFn) in the console | you 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
| Panel | The question it answers | The control people miss |
|---|---|---|
| Network | was the request sent, with what, and what came back? | right click a row, Copy as fetch, then replay it in the console |
| Network | is it slow, or is it slow for users? | throttling presets, plus Disable cache while devtools is open |
| Performance | why did the interaction feel sluggish? | record, then look for long tasks over 50ms in the flame chart |
| Performance | is it my code or layout and paint? | the summary donut splits scripting, rendering and painting |
| Memory | why does it get slower the longer it runs? | two heap snapshots, then compare, then look at retainers |
| Coverage | which of this bundle actually ran? | record a session, read the unused bytes column |
A method: bisect the problem
- 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. State the expectation precisely "It is broken" is not a bug report. "
totalshould be 12.50 and is'1250'" tells you the type is wrong, not the arithmetic. - 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. 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. 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. 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 rerunsShotgun 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.countkeeps 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.logyou 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.assertas a guard. It never throws and never halts. - Leaving
debuggerstatements 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
consoleis a toolkit:tablefor rows,countfor frequency,groupfor phases,dirfor structure,tracefor callers.console.assertreports 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.