JavaScript (ES2026) Module 9 – Errors and debugging
Debugging in DevTools and Node
Reproduce and shrink a bug, use breakpoints, conditional breakpoints and logpoints in DevTools and Node.js, and read minified stack traces with source maps.
What you will learn
- Apply a method to a bug: reproduce it, shrink it, form a hypothesis and test it
- Use breakpoints, conditional breakpoints, logpoints, watch expressions and the call stack
- Debug Node.js programs with --inspect and Chrome DevTools, or with node inspect in a terminal
- Map minified or compiled code back to its sources with source maps
Before you start
What you’ll learn · Before you start
What you will learn
- Apply a method to a bug: reproduce it, shrink it, form a hypothesis and test it
- Use breakpoints, conditional breakpoints, logpoints, watch expressions and the call stack
- Debug Node.js programs with --inspect and Chrome DevTools, or with node inspect in a terminal
- Map minified or compiled code back to its sources with source maps
Before you start
On this page
Debugging is finding out why a program does what it does instead of what you meant. A debugger makes that a matter
of looking rather than guessing: it pauses the running program at a line you choose, a breakpoint, shows every
variable in scope and the chain of calls that led there, and steps through the code one line at a time. Browsers have
one in their developer tools, the Sources panel in Chrome; Node.js offers the same protocol with --inspect, so Chrome
DevTools can attach to it, and has a terminal debugger, node inspect. When the code that runs is minified or compiled,
source maps lead from its positions back to the source you wrote. Tools help only inside a method, though: make the
bug happen on demand, shrink it, form one hypothesis and test it. This lesson follows that method on a cart whose
discount is wrong, with a debugging session recorded as it ran.
A method before any tool
A method for finding bugs
Text description of the diagram
The chart shows five steps from top to bottom, with one loop.
1. **Reproduce:** find a failing input you can run again, ideally as a test. 2. **Shrink:** cut it down to the smallest input and the least code that still fail. 3. **Form a hypothesis:** make one specific claim about the cause. 4. **Test it:** use a breakpoint, a watch expression or a logpoint that could prove the claim wrong. If it is proved wrong, go back to step 3 with what you learned and form another hypothesis. 5. **Fix the cause** once a hypothesis is confirmed, and keep the failing input as a test so that the bug cannot come back unnoticed.
Reproduce comes first because a bug you cannot trigger cannot be checked as fixed. Write the failing input down,
ideally as a test, and note the exact versions it fails on. Shrink it next: remove input and code until nothing more
can go without the failure going too. When the bug is new, git bisect finds the commit that introduced it by a binary
search through the history, with git bisect run testing each candidate automatically. Then form one hypothesis,
a claim specific enough to be wrong (“the coupon’s cap is applied as a minimum”), and test it with an observation
that could disprove it. If it is disproved, the new fact goes into the next hypothesis. When one survives, fix the
cause and keep the failing input as a regression test.
The bug: a discount that is too big
A cart holds two packs of tea at 120.00 and a notebook at 45.00, 285.00 in all. A coupon gives 10 % off, at most
50.00, so the discount should be 28.50 and the total 256.50, but checkout says 235.00. Reading shop.mjs might reveal
the mistake, and in a large program it often would not; a debugger shows it without guessing.
The session below was recorded by debug-session.mjs. It starts a small debugger in a worker thread, which connects to
the program’s own inspector, as the Node.js documentation recommends for setting breakpoints from code, and speaks the
same protocol as Chrome DevTools. It sets three breakpoints before shop.mjs loads, runs the checkout, and prints what
it saw at each pause.
JavaScript · debug-session.mjs
// A recorded debugging session of shop.mjs: a logpoint, a conditional breakpoint and a breakpoint with watches,
// set through Node.js's inspector before the module loads.
import { once } from 'node:events';
import { Worker } from 'node:worker_threads';
const breakpoints = [
// A logpoint: a condition that logs and is never true, so the program does not stop.
{ line: 5, condition: "console.log(`logpoint: ${item.sku} x ${item.qty} = ${item.qty * item.unitPaise}`), false", watch: [] },
// A conditional breakpoint: stops only for lines with more than one unit.
{ line: 6, condition: 'item.qty > 1', watch: ['item', 'line', 'subtotal'] },
// A plain breakpoint before the return, with the call stack and three watch expressions.
{ line: 10, stack: true, watch: ['subtotal', 'percentOff', 'discount'] },
];
const debuggerThread = new Worker(new URL('./debugger-worker.mjs', import.meta.url), { workerData: { file: 'shop.mjs', breakpoints } });
await once(debuggerThread, 'message'); // the breakpoints are set
const { checkout } = await import('./shop.mjs');
const items = [{ sku: 'TEA-250', qty: 2, unitPaise: 12000 }, { sku: 'NOTE-A5', qty: 1, unitPaise: 4500 }];
console.log(`checkout says: ${checkout(items, { percent: 10, capPaise: 5000 })} (expected: total 256.50)`);
debuggerThread.postMessage('done');
const [{ pauses }] = await once(debuggerThread, 'message');
await debuggerThread.terminate();
console.log('\nWhat the debugger saw at each pause:');
pauses.forEach((lines, i) => console.log(`${i + 1}. ${lines.join('\n ')}`)); Output
logpoint: TEA-250 x 2 = 24000
logpoint: NOTE-A5 x 1 = 4500
checkout says: total 235.00 (expected: total 256.50)
What the debugger saw at each pause:
1. shop.mjs:6 in cartTotal, because item.qty > 1
item = {"sku":"TEA-250","qty":2,"unitPaise":12000}
line = 24000
subtotal = 0
2. shop.mjs:10 in cartTotal
call stack: cartTotal <- checkout <- (module)
subtotal = 28500
percentOff = 2850
discount = 5000
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node debug-session.mjs
shop.mjs
// The code under the debugger: a cart total with a coupon whose discount should be capped.
export function cartTotal(items, coupon) {
let subtotal = 0;
for (const item of items) {
const line = item.qty * item.unitPaise;
subtotal += line;
}
const percentOff = Math.round((subtotal * coupon.percent) / 100);
const discount = Math.max(percentOff, coupon.capPaise);
return subtotal - discount;
}
export function checkout(items, coupon) {
const total = cartTotal(items, coupon);
return `total ${(total / 100).toFixed(2)}`;
} debugger-worker.mjs
// A small debugger: it runs in a worker thread, connects to the main thread's inspector, as the Node.js documentation
// recommends for breakpoints, and talks the Chrome DevTools Protocol, the same protocol DevTools uses.
import { Session } from 'node:inspector';
import { parentPort, workerData } from 'node:worker_threads';
const session = new Session();
const pauses = [];
// When the program is done, hand over what was seen; the main thread then stops this thread.
parentPort.on('message', (message) => {
if (message === 'done') parentPort.postMessage({ pauses });
});
session.connectToMainThread();
const post = (method, params) => new Promise((resolve, reject) => session.post(method, params, (error, result) => (error ? reject(error) : resolve(result))));
const watches = new Map(); // breakpoint id -> what to show when it pauses
session.on('Debugger.paused', async ({ params }) => {
const [frame] = params.callFrames;
const spec = watches.get(params.hitBreakpoints[0]);
const lines = [`${workerData.file}:${frame.location.lineNumber + 1} in ${frame.functionName}${spec.condition ? `, because ${spec.condition}` : ''}`];
if (spec.stack) lines.push(`call stack: ${params.callFrames.map((f) => f.functionName || '(module)').join(' <- ')}`);
for (const expression of spec.watch) {
const { result } = await post('Debugger.evaluateOnCallFrame', { callFrameId: frame.callFrameId, expression: `JSON.stringify(${expression})` });
lines.push(`${expression} = ${result.value}`);
}
pauses.push(lines);
await post('Debugger.resume');
});
await post('Debugger.enable');
for (const spec of workerData.breakpoints) {
const urlRegex = `${workerData.file.replace(/\./g, '\\.')}$`;
const { breakpointId } = await post('Debugger.setBreakpointByUrl', { lineNumber: spec.line - 1, urlRegex, condition: spec.condition ?? '' });
watches.set(breakpointId, spec);
}
parentPort.postMessage('ready'); - A logpoint on line 5 printed each line’s amount without stopping the program. It is a breakpoint whose condition
calls
console.logand evaluates tofalse, so the debugger never pauses there; DevTools offers the same as “Add logpoint”. - A conditional breakpoint on line 6 paused only where
item.qty > 1was true, once for the tea, instead of on every pass of the loop. In a loop over 10,000 items, that difference is what makes a breakpoint usable. - A breakpoint on line 10 paused before the return, with the call stack,
cartTotal, called fromcheckout, called from the module, and three watch expressions.
The last pause holds the answer: percentOff is 2850, the 10 %, but discount is 5000, the cap. The code took the
larger of the two values instead of the smaller, so line 9 needs Math.min instead of Math.max: the hypothesis “the cap
is applied as a minimum” survived. Note that the pauses are listed after the program’s own output only because the
session collects them while the program is stopped and prints them at the end.
Breakpoints in the browser’s developer tools
In Chrome, open the developer tools, go to the Sources panel and open the file. A click on a line number sets a breakpoint; a right-click offers a conditional breakpoint and a logpoint. While the code is paused, the Scope pane lists the variables, the Watch pane evaluates expressions you add, and the Call Stack pane shows how the code got there.
| Kind | How it is set | Use it when |
|---|---|---|
| Line breakpoint | Click the line number | You know where to look |
| Conditional breakpoint | Right-click the line number, add a condition | Only some passes through a line matter |
| Logpoint | Right-click the line number, add a message | You want console.log output without editing the code |
| Exception breakpoint | “Pause on uncaught exceptions”, “Pause on caught exceptions” | You want to stop where an error is thrown, not where it is reported |
| DOM, fetch and event listener breakpoints | The Elements panel, and the panes of the Sources panel | You do not know which code changes a node, sends a request or handles a click |
| Action while paused | Key in Chrome |
|---|---|
| Resume | F8 |
| Step over the next call | F10 |
| Step into the next call | F11 |
| Step out of the current function | Shift+F11 |
The debugger; statement sets a breakpoint from inside the code. ECMA-262 lets it pause only when a debugger is
present and active, so it does nothing for visitors without the developer tools open, but it should still never ship:
ESLint’s recommended no-debugger rule reports it.
Debugging Node.js programs
node --inspect-brk app.mjs starts a program with its inspector open and stops before the first line runs; plain
--inspect does not wait. Open chrome://inspect in Chrome, where the program appears under Remote Target, and
DevTools attaches with the same Sources panel as for a page. Code editors such as Visual Studio Code attach to the same
port. Without a browser, node inspect app.mjs starts a debugger in the terminal:
| Command | What it does |
|---|---|
sb(12), sb('shop.mjs', 6, 'item.qty > 1') |
Set a breakpoint on a line of the current file, or a conditional one in a file |
c, n, s, o |
Continue, step to the next line, step into, step out |
watch('discount'), watchers |
Add a watch expression, list the watched values |
exec subtotal, repl |
Evaluate an expression, or open a prompt in the paused scope |
bt |
Print the backtrace, the call stack |
Node.js 24.16 added an experimental probe mode for printf-style debugging without editing code:
node inspect --probe shop.mjs:10 --expr 'discount' app.mjs prints the value each time execution reaches that line.
The inspector is powerful, which makes it a door into the process. It listens on 127.0.0.1:9229 by default, reachable
only from the same machine, and the Node.js documentation warns that binding it to a public address, including
0.0.0.0, allows remote code execution. To debug a remote server, keep the default and forward the port over SSH, as in
ssh -L 9221:localhost:9229 user@server, then attach to localhost:9221. A running process can be told to open its
inspector with the SIGUSR1 signal, which Node.js reserves for that purpose.
Source maps: from minified code back to your source
Bundlers and compilers turn your files into code that is smaller or targets older engines, and an error from that code
points at positions nobody can read, such as line 1, column 49 of a minified file. A source map is a JSON file that
maps positions in the generated code back to files, lines, columns and names of the original; its format is
standardised by Ecma as ECMA-426. The generated file names its map in a comment such as
//# sourceMappingURL=cart.min.mjs.map.
JavaScript · sourcemap-stack.mjs
// The same error from the minified bundle, first as Node.js reports it by default, then with source maps applied.
import { setSourceMapsSupport } from 'node:module';
const items = [{ qty: 1, pricePaise: 12000 }, { qty: '2', pricePaise: 25000 }];
const topFrames = (error) => error.stack.split('\n').slice(0, 3).join('\n');
const plain = await import('./dist/cart.min.mjs');
try {
plain.cartTotalPaise(items);
} catch (error) {
console.log(`Without source maps:\n${topFrames(error)}`);
}
// The same as starting Node.js with --enable-source-maps, for modules loaded from now on.
setSourceMapsSupport(true);
const mapped = await import('./dist/cart.min.mjs?with-source-maps');
try {
mapped.cartTotalPaise(items);
} catch (error) {
console.log(`\nWith source maps:\n${topFrames(error)}`);
} Output
Without source maps:
TypeError: qty must be a whole number, got "2"
at n (dist/cart.min.mjs:1:49)
at Module.o (dist/cart.min.mjs:1:189)
With source maps:
TypeError: qty must be a whole number, got "2"
at linePaise (src/cart.mjs:5:11)
at Module.o (src/cart.mjs:12:38)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sourcemap-stack.mjs
src/cart.mjs · src/cart.mjs
// What the team wrote. dist/cart.min.mjs is this file after the build (esbuild 0.28.2: --bundle --minify
// --sourcemap --format=esm), and dist/cart.min.mjs.map maps its positions back here.
function linePaise(item) {
if (!Number.isInteger(item.qty)) {
throw new TypeError(`qty must be a whole number, got ${JSON.stringify(item.qty)}`);
}
return item.qty * item.pricePaise;
}
export function cartTotalPaise(items) {
let total = 0;
for (const item of items) total += linePaise(item);
return total;
} dist/cart.min.mjs · dist/cart.min.mjs
function n(t){if(!Number.isInteger(t.qty))throw new TypeError(`qty must be a whole number, got ${JSON.stringify(t.qty)}`);return t.qty*t.pricePaise}function o(t){let r=0;for(let e of t)r+=n(e);return r}export{o as cartTotalPaise};
//# sourceMappingURL=cart.min.mjs.map Without source maps, the trace names functions n and o at columns of the single line of the bundle. With them, the
same error points at linePaise in src/cart.mjs, line 5, where the TypeError is thrown, and at line 12, the loop
that called it. The second frame is still named o: positions map reliably, while names are restored only where the
map records them. Developer tools in browsers load source maps by themselves when they are open. Node.js applies them to
stack traces only when asked: start it with --enable-source-maps, or call module.setSourceMapsSupport(true) before
loading the code, as the example does. The documentation warns that reading error.stack becomes slower with source
maps on, which matters only for programs that do it constantly.
In production, keep the maps where your error reports can use them without serving them to every visitor if your source should stay private, for example by uploading them to your error tracker as part of each release.
Source Map Stack Trace Decoder (Unminify JavaScript Errors) Paste a minified stack trace and its .map file to see the original files, lines and functions. JavaScript Formatter Spread a one-line minified file over readable lines before you step through it.console.log, used on purpose
Printing values is a legitimate debugging tool when it is deliberate: print the value you are checking, labelled, at the point where your hypothesis says it goes wrong, and remove the line once you know. A logpoint does the same without touching the file. One trap belongs to browsers: their console reads a logged object’s contents when you expand it, not when it was logged, so log a copy, or the fields you need, when the object keeps changing. The console and reading error messages covers the console’s other methods.
Exercise: find the line where the invoice goes wrong
Exercise · Medium · JavaScript
Find the line where the invoice goes wrong
A shop shows an invoice preview while the customer edits the cart. Customers report that the preview is right the first time and then drops every time the page refreshes it, although nothing in the cart changed. invoice(lines) is the code behind the preview; amounts are in paise.
- Reproduce it first: run the sample tests and look at which ones fail, or call
invoicetwice with the same lines and print both results. - Find the line where the first wrong value appears. Copy the file into a project and run a small script under
node inspector withnode --inspect-brkand Chrome DevTools; put a breakpoint insidelineAmountand watchline.unitPaiseduring the first call and the second one. - Fix it so that every sample test passes, without changing what one call of
invoicereturns.
Starter code · invoice.mjs
/** Tax added to every invoice, as a fraction of the subtotal. */
export const TAX_RATE = 0.18;
/**
* The amount of one invoice line in paise: quantity times unit price, after the line's discount.
* @param {{ sku: string, qty: number, unitPaise: number, discountPercent?: number }} line
* @returns {number}
*/
function lineAmount(line) {
if (line.discountPercent) {
line.unitPaise = Math.round(line.unitPaise * (1 - line.discountPercent / 100));
}
return line.qty * line.unitPaise;
}
/**
* The totals of an invoice, in paise.
* @param {{ sku: string, qty: number, unitPaise: number, discountPercent?: number }[]} lines
* @returns {{ subtotal: number, tax: number, total: number }}
*/
export function invoice(lines) {
let subtotal = 0;
for (const line of lines) {
subtotal += lineAmount(line);
}
const tax = Math.round(subtotal * TAX_RATE);
return { subtotal, tax, total: subtotal + tax };
} The sample tests · invoice.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { invoice } from './invoice.mjs';
const lines = () => [
{ sku: 'TEA-250', qty: 2, unitPaise: 12000, discountPercent: 10 },
{ sku: 'MUG-01', qty: 1, unitPaise: 34900 },
];
test('a line without a discount: qty times price, plus 18 % tax', () => {
assert.deepEqual(invoice([{ sku: 'MUG-01', qty: 3, unitPaise: 34900 }]), { subtotal: 104700, tax: 18846, total: 123546 });
});
test('a discounted line is charged at the discounted price', () => {
assert.deepEqual(invoice(lines()), { subtotal: 56500, tax: 10170, total: 66670 });
});
test('the same lines give the same invoice every time', () => {
const same = lines();
const first = invoice(same);
assert.deepEqual(invoice(same), first, 'the second preview of the same lines must not change');
});
test('invoice leaves the lines it was given unchanged', () => {
const given = lines();
invoice(given);
assert.deepEqual(given, lines());
});
test('an empty invoice is all zeros', () => {
assert.deepEqual(invoice([]), { subtotal: 0, tax: 0, total: 0 });
}); A hint
Watch the input, not only the result: compare lines[0].unitPaise before the first call and after it. A function that receives an object can change that object for its caller too, so the second call starts from a price that the first call already discounted.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Self-check
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What does the debugger statement do, and when does it pause the program?
debugger; is a statement that asks the engine to pause at that point, like a breakpoint written into the code.
ECMA-262 leaves the effect to the implementation and says that without a debugger present and active it has no
observable effect. So in a browser it pauses only while the developer tools are open, and in Node.js only when an
inspector client is attached, for example after node --inspect-brk or under node inspect. While paused, you can
look at variables, the call stack and watch expressions, and step on line by line. It is useful when setting a
breakpoint in the tools is awkward, such as in code that is generated or loaded late. It must not ship: ESLint’s
recommended no-debugger rule reports it, and a breakpoint set in the tools or a logpoint does the same job without
changing the file.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
About 1 in 500 checkouts in production shows a total that is a few rupees off, and nobody can reproduce it on a laptop. Walk through how you would find the bug.
-
Mid to senior
To debug a staging service in a container, a teammate starts Node.js with --inspect=0.0.0.0:9229 and publishes the port so that their laptop can attach. What is the risk, and how would you debug that service safely?
Key takeaways
- Reproduce, shrink, form one hypothesis, test it, then fix the cause and keep the failing input as a test;
git bisectfinds the commit that introduced a regression. - A line breakpoint pauses on a line, a conditional breakpoint only when its expression is true, and a logpoint prints without pausing; the Scope, Watch and Call Stack panes show the state at the pause.
node --inspect-brkwithchrome://inspectgives Node.js programs the DevTools debugger;node inspectis the terminal version.- The inspector listens on
127.0.0.1:9229; never bind it to a public address, and use an SSH tunnel to debug a remote machine. - Source maps, standardised as ECMA-426, lead from minified positions to your source; Node.js needs
--enable-source-mapsormodule.setSourceMapsSupport(true). debugger;pauses only when a debugger is attached;no-debuggerkeeps it out of shipped code.
References
- ECMAScript 2026 Language Specification: the debugger statement (Ecma International)
- Pause your code with breakpoints (Chrome for Developers)
- Keyboard shortcuts (Chrome DevTools) (Chrome for Developers)
- Node.js 24 documentation: Debugger (OpenJS Foundation (Node.js))
- Node.js 24 documentation: command-line options (--inspect, --enable-source-maps) (OpenJS Foundation (Node.js))
- Debugging Node.js (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Inspector (OpenJS Foundation (Node.js))
- Node.js 24 documentation: module.setSourceMapsSupport (OpenJS Foundation (Node.js))
- ECMA-426: Source map format specification (Ecma International)
- git-bisect (Git)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress