Advanced debugging
Learn conditional breakpoints, logpoints, async stack traces, source maps, and Node.js debugging workflows for production JavaScript bugs.
- 01Stop only when evidence mattersUse conditional breakpoints, hit counts, event and DOM breakpoints, logpoints, Watch, and the Ignore List deliberately.
- 02Read modern async stacksExplain V8 async stack traces,
Error.captureStackTrace,error.cause,console.trace, and stack trace limits. - 03Debug production-shaped codeMap bundles with source maps, attach Node inspectors, and isolate bugs with reproduction, bisecting, and verification.
Advanced debugging means asking narrower questions
You already know the basics from Debugging in the browser: set a breakpoint, step, read Scope, and add Watch expressions. You also learned in Reading error messages that the top stack frame is evidence, not a verdict. This lesson builds on those skills instead of repeating them.
Advanced debugging is the habit of narrowing a bug with a reproducible case, targeted pauses, non-invasive traces, async stack evidence, source maps, and runtime-specific tools until one hypothesis can be verified.
The tools are sharper than a plain line breakpoint: conditional breakpoints, hit counts, logpoints, DOM breakpoints, event listener breakpoints, XHR/fetch breakpoints, exception pause, Ignore List, async stack traces, and the Node inspector. The professional move is choosing the smallest tool that answers the current question.
A mechanic does not replace random parts. They reproduce the fault, read the recorder, isolate one subsystem, test a theory, and only then change the part. Debugging production JavaScript should feel the same.
- In real life: Flight recorder captures the important readings
- In JavaScript: Logpoints and traces collect evidence without changing the bug
- In real life: Mechanic checks one subsystem at a time
- In JavaScript: Conditional breakpoints isolate one state in a loop
- In real life: Maintenance manual maps part numbers to real parts
- In JavaScript: Source maps connect generated code to authored source
- In real life: A supervisor asks for a repeatable failure
- In JavaScript: Reproduce, isolate, hypothesize, verify
Where the analogy stops: Real aircraft sensors are installed before the incident. Debuggers can pause and inspect a live program, but pausing can also change timing, especially in async and UI bugs.
You will connect this lesson to try/catch, custom errors, async/await, promise errors, bundlers and source maps, and JavaScript runtimes.
A method before tools
WORKFLOWAdvanced tools are useful only after the bug is repeatable. Use a simple loop: reproduce the failure, isolate the smallest path, hypothesize one cause, verify with an observation, then fix the smallest line and rerun from the start.
- Reproduce: write the exact steps, input, browser, Node version, or test command.
- Isolate: remove unrelated code, data, requests, or changes until the bug still appears.
- Hypothesize: state one falsifiable guess, like “the third click submits twice.”
- Verify: pick the least invasive observation: condition, logpoint, Watch expression, trace, or failing test.
Binary search is the same method with math. If a list of changes contains one bad change, test the middle. If the middle fails, search the first half; if it passes, search the second half. Rubber-ducking helps because saying each value aloud often reveals the assumption you never tested.
const changes = [ { id: "a-route", label: "add account route" }, { id: "b-layout", label: "move checkout layout" }, { id: "c-copy", label: "rewrite button copy" }, { id: "discount-formula", label: "replace discount formula" }, { id: "e-theme", label: "adjust theme tokens" }, { id: "receipt-cache", label: "cache receipt payload" },]; function testUpTo(index) { return changes.slice(0, index + 1).some(change => change.id === "discount-formula") ? "fail" : "pass";} function bisectFirstBad() { let low = 0; let high = changes.length - 1; const steps = []; while (low < high) { const mid = Math.floor((low + high) / 2); const result = testUpTo(mid); steps.push(`test through ${changes[mid].id}: ${result}`); if (result === "fail") high = mid; else low = mid + 1; } return { first: changes[low], steps };} const report = bisectFirstBad();console.log(report.first.label);console.log(report.steps.join(" | "));replace discount formulatest through c-copy: pass | test through e-theme: fail | test through discount-formula: failBinary search found discount-formula after 3 checks instead of reading every change in order.
Write down the reproduction, what you tried, what you observed, and what changed. It prevents loops like “maybe it is the API” becoming an hour of repeated guesses.
Conditional breakpoints and logpoints
DEVTOOLSIn Chrome DevTools, open the Sources panel, right-click a line number, and choose Add conditional breakpoint... or Add logpoint.... The official UI also includes DOM breakpoints from the Elements panel’s Break on menu, Event Listener Breakpoints, XHR/fetch Breakpoints, and the Pause on exceptions control. Firefox DevTools uses the same names for conditional breakpoints, logpoints, Event Listener Breakpoints, XHR breakpoints, and source maps; it still commonly calls skipped library files blackboxed sources.
// Chrome DevTools Sources panel names to look for:// Add conditional breakpoint... item.id === 42// Add logpoint... "item", item.id, item.stock// DOM breakpoints subtree modifications, attribute modifications, node removal// Event Listener Breakpoints click, input, timer, animation, and more// XHR/fetch Breakpoints pause when the request URL contains text// Pause on exceptions uncaught only, or caught and uncaught// Ignore List skip framework or extension scripts while steppingConditional breakpoints evaluate an expression in the paused line’s scope, such as item.id === 42. Hit counts answer repeated-action bugs: pause on the third click, the tenth timer tick, or the second call from a test. Logpoints print without editing source and without pausing, which is safer when pausing changes timing.
Step through a loop bug the way a conditional breakpoint works: skip repeated states until the predicate is true, then inspect the matching state.
script
{ id: 17, label: "keyboard", stock: 4 }, { id: 42, label: "monitor", stock: -1 }, { id: 58, label: "dock", stock: 3 },]; function firstBrokenItem(items, breakWhen) { const log = []; for (const item of items) { log.push(`checking ${item.id}`); if (breakWhen(item)) { return { item, log }; } } return { item: null, log };} const result = firstBrokenItem(items, item => item.id === 42);console.log(result.item.id);console.log(result.log.join(" -> "));const items = [ { id: 17, label: "keyboard", stock: 4 }, { id: 42, label: "monitor", stock: -1 }, { id: 58, label: "dock", stock: 3 },]; function firstBrokenItem(items, breakWhen) { const log = []; for (const item of items) { log.push(`checking ${item.id}`); if (breakWhen(item)) { return { item, log }; } } return { item: null, log };} const result = firstBrokenItem(items, item => item.id === 42);console.log(result.item.id);console.log(result.log.join(" -> "));42: monitorchecking 17 -> checking 42The helper recorded the first state where item.id === 42 is true: item 42, monitor.
A logpoint is often the bridge between console.log and a breakpoint. It gives a clue while the program keeps running. The local tracer below is not DevTools, but it demonstrates the same idea: print only when a condition is true, and keep the source behavior otherwise unchanged.
function withConditionalTrace(fn, shouldLog) { return (...args) => { const result = fn(...args); if (shouldLog(args, result)) { console.log(`${fn.name}(${args.join(", ")}) -> ${result}`); } return result; };} const priceWithTax = (price, rate) => Number((price * (1 + rate)).toFixed(2));const tracedPrice = withConditionalTrace(priceWithTax, (args, result) => result > 50);console.log(tracedPrice(20, 0.1));console.log(tracedPrice(60, 0.1));22b(60, 0.1) -> 6666
Only the call whose result is greater than 50 emits the trace line, similar to a conditional logpoint.
- A loop has 500 items, but only
item.id === 42is wrong. - The bug appears on the third click and not before.
- You need to print
totalfrom a bundled file without changing source. - A modal disappears but no one knows which script removed it.
- A handler registered somewhere responds to every input event.
- A promise rejection is caught before the Console shows the original line.
- A checkout request goes to the wrong URL.
Sort each scenario by the first DevTools feature you would reach for. Several tools can help later; choose the smallest first move.
| Tool | Use it when | Cost to remember |
|---|---|---|
| console.log | You can safely edit local code and want a permanent print while testing. | Changes source, can be forgotten, and may not match production bundles. |
| Logpoint | You want Console output from a line without changing the file or pausing. | DevTools-only; not a replacement for real logging or telemetry. |
| Conditional breakpoint | A line runs many times but only one state matters, such as item.id === 42. | The expression itself can have side effects if you write one. |
| debugger statement | A local experiment or hard-to-find generated file needs a temporary pause. | Must be removed before sharing; it can surprise anyone with DevTools open. |
Async stack traces: follow the await chain
STACKSA synchronous stack is the current call stack. Older JavaScript engines often stopped at the event loop boundary: after a promise, timer, or callback resumed, the original caller was gone. Modern V8 reconstructs many await chains with “zero-cost async stack traces,” so Node and Chrome can show frames such as at async outer below the throwing function.
Error.stackTraceLimit = 20; async function leaf() { await Promise.resolve(); throw new Error("boom from leaf");} async function middle() { await leaf();} async function outer() { await middle();} outer().catch(error => { console.log(error.stack.split("\n").slice(0, 5).join("\n"));});Stack strings are useful but not standardized. V8 writes frames like at async outer (file.js:12:3). Firefox uses a different shape, often outer@file.js:12:3. Treat stacks as human-readable evidence, not a portable data format.
function createInputError(message) { const error = new Error(message); Error.captureStackTrace?.(error, createInputError); return error;} function validateCart() { return createInputError("missing total");} console.log(validateCart().stack);Error.captureStackTrace is V8-specific. It lets a library create an error and omit helper frames above a constructor or factory function. For cross-runtime code, guard it with optional chaining and keep the error useful without it.
const networkError = new Error("request failed");const checkoutError = new Error("checkout failed", { cause: networkError }); console.log(checkoutError.message);console.log(checkoutError.cause.message);error.cause connects a high-level error to the lower-level failure without flattening both messages into one string. console.trace() prints the current stack without throwing. Error.stackTraceLimit changes how many V8 frames are collected. These are diagnostic tools, not business logic.
function leaf() { console.trace("discount path");} function outer() { leaf();} outer();Debugging with source maps
MAPPINGA source map connects generated code back to authored code. Bundlers and transpilers may minify, concatenate, or turn TypeScript into JavaScript. The browser runs the generated file, but DevTools can display and breakpoint the original source when it can find the map.
export function originalName(total: number) { throw new Error("mapped failure for " + total);} originalName(42);export function originalName(total) { throw new Error("mapped failure for " + total);}originalName(42);//# sourceMappingURL=checkout.js.mapThe comment //# sourceMappingURL=checkout.js.map is the breadcrumb DevTools or Node follows. In Chrome, source maps can be enabled or disabled in Settings Preferences Sources with Enable JavaScript source maps, or through the Command Menu by searching for source maps. If a framework or map marks vendor files as ignored, Chrome’s Ignore List skips them while stepping; many developers still say “blackboxing.”
| Place | What the map does | Debugging habit |
|---|---|---|
| Browser DevTools | Maps minified or transpiled code back to authored source when JavaScript source maps are enabled. | Settings > Preferences > Sources > Enable JavaScript source maps, or Command Menu: source map. |
| Production web build | Often keeps maps private while still uploading them to an error tracker. | Use a hidden-source-map style output and do not publish a public sourceMappingURL comment. |
| Node.js | Can remap stack traces from generated JavaScript to TypeScript or bundled sources. | Run with node --enable-source-maps dist/file.js and keep the .map beside the generated file. |
# Browser builds usually attach a comment near the end of the file:# //# sourceMappingURL=checkout.js.map # Node can remap stack traces when the .js file and .map file are present:node --enable-source-maps dist/checkout.js # Many production web builds keep maps private instead:# use a hidden-source-map setting, upload the .map files to your error tracker,# and do not publish sourceMappingURL comments for users to download.Public source maps can reveal original file names and source. That may be fine for open-source code; it may be wrong for private apps. Many teams upload maps to an error tracker and keep them off the public CDN.
Debugging Node.js
RUNTIMENode can be debugged with the same inspector protocol Chrome DevTools uses. Start a process with --inspect, open chrome://inspect, and attach. Use --inspect-brk when the bug happens during startup and you need to pause before the first line of user code.
# Start Node with the inspector and attach Chrome DevTools from chrome://inspect.node --inspect server.jsnode --inspect-brk server.js # Debug the built-in test runner before the first line runs.node --test --inspect-brk tests/checkout.test.mjs # Use the terminal debugger when you only have a shell.node inspect server.jsVS Code’s JavaScript Debug Terminal automatically launches Node programs with debugging enabled. A launch.json can pin the program, arguments, environment, and source-map behavior for a team. When you only have a terminal, node inspect provides a CLI debugger. For tests, node --test --inspect-brk pauses the built-in test runner before the test file executes.
NODE_DEBUG=http,net node server.js node -e "const util = require('node:util'); console.log(util.inspect(obj, { depth: 4 }))" process.on('uncaughtException', (error) => { console.error(error); process.exitCode = 1;}); process.on('unhandledRejection', (reason) => { console.error(reason); process.exitCode = 1;});NODE_DEBUG=http,net prints built-in subsystem diagnostics. util.inspect(value, { depth: 4 }) controls how deeply objects are printed. Process handlers for uncaughtException and unhandledRejection are last-resort reporting hooks: log, set a failing exit code, and fix the original error path rather than pretending the process is healthy.
Practical workflows: the third click, loop value, and swallowed rejection
REAL BUGSThree production-shaped bugs show how the tools combine. A bug that only happens on the third click wants a hit count, event listener breakpoint, or condition. A wrong value inside a loop wants item.id === 42 or a logpoint. A swallowed promise rejection wants exception pause, async stacks, and an error cause chain.
This replay shows a bug that only appears on the third click. Step until the condition becomes true, then inspect the flag that changed.
script
let submitted = false; function handleCheckoutClick() { clickCount += 1; if (clickCount === 3) { submitted = true; return "submitted twice"; } return "waiting";} console.log(handleCheckoutClick());console.log(handleCheckoutClick());console.log(handleCheckoutClick());async function saveOrder() { throw new Error("network down");} async function checkout() { try { await saveOrder(); return "saved"; } catch (error) { return "saved"; // bug: the rejection was swallowed }} checkout().then(status => console.log(status));The bad code returns "saved" even when saveOrder throws. Pause on caught exceptions to stop at the throw site, then fix the path so callers see a meaningful failure and the original cause stays attached.
async function saveOrder() { throw new Error("network down");} async function checkout() { try { await saveOrder(); return { ok: true }; } catch (error) { throw new Error("checkout failed", { cause: error }); }} checkout().catch(error => console.log(error.cause.message));function submitForm(form) { // Temporary local-only pause. Remove it before sharing code. debugger; return form.checkValidity();}A debugger; statement is a real JavaScript pause for local work. It is useful when generated files make line breakpoints hard to place. Do not leave it in shared code; prefer DevTools breakpoints or tests once the bug is understood.
Common misconceptions
“A conditional breakpoint is harmless because it only reads.”
It is just JavaScript. If the expression calls a function with side effects, the debugger can change the program.
“Logpoints are production logging.”
Logpoints are temporary DevTools observations. Production logging needs intentional code, privacy review, and retention rules.
“Async stacks are standardized strings.”
They are engine-specific strings. Assert important function names in tests, not exact file paths or line numbers.
“Source maps fix the bug.”
They only map evidence back to source. You still need to reproduce, isolate, hypothesize, and verify.
“An unhandled rejection handler means the app recovered.”
It means you logged a last-resort failure. Treat the process or request as unhealthy until the original path is fixed.
| Confusion | Better mental model | Safer action |
|---|---|---|
| Pause vs observe | Pausing changes timing; observing may be enough. | Try a logpoint before pausing animation, timers, or network races. |
| First frame vs root cause | The top frame threw; a caller may have passed bad data. | Read down the stack and preserve error.cause. |
| Generated file vs authored source | The runtime runs generated JavaScript. | Use source maps to inspect the source you maintain. |
Practice exercises
5 EXERCISESA loop only fails for the item whose id is 42. What expression should the conditional breakpoint use?
const item = { id: 42 };
console.log(item.id === 42);The expression item.id === 42 is true only for the item whose id is 42, so DevTools skips the other iterations.
Read the starter code. What string does the third click return?
let clickCount = 0;
let submitted = false;
function handleCheckoutClick() {
clickCount += 1;
if (clickCount === 3) {
submitted = true;
return "submitted twice";
}
return "waiting";
}
console.log(handleCheckoutClick());
console.log(handleCheckoutClick());
console.log(handleCheckoutClick());The third call returns submitted twice. A hit count of 3 or a condition around clickCount would pause at that moment.
Which trace line appears only for the expensive call?
function withConditionalTrace(fn, shouldLog) {
return (...args) => {
const result = fn(...args);
if (shouldLog(args, result)) {
console.log(`${fn.name}(${args.join(", ")}) -> ${result}`);
}
return result;
};
}
const priceWithTax = (price, rate) => Number((price * (1 + rate)).toFixed(2));
const tracedPrice = withConditionalTrace(priceWithTax, (args, result) => result > 50);
console.log(tracedPrice(20, 0.1));
console.log(tracedPrice(60, 0.1));The trace line is priceWithTax(60, 0.1) -> 66, printed before the normal 66 output.
Run the bisect mentally. Which change label did it find first?
const changes = [
{ id: "a-route", label: "add account route" },
{ id: "b-layout", label: "move checkout layout" },
{ id: "c-copy", label: "rewrite button copy" },
{ id: "discount-formula", label: "replace discount formula" },
{ id: "e-theme", label: "adjust theme tokens" },
{ id: "receipt-cache", label: "cache receipt payload" },
];
function testUpTo(index) {
return changes.slice(0, index + 1).some(change => change.id === "discount-formula")
? "fail"
: "pass";
}
function bisectFirstBad() {
let low = 0;
let high = changes.length - 1;
const steps = [];
while (low < high) {
const mid = Math.floor((low + high) / 2);
const result = testUpTo(mid);
steps.push(`test through ${changes[mid].id}: ${result}`);
if (result === "fail") high = mid;
else low = mid + 1;
}
return { first: changes[low], steps };
}
const report = bisectFirstBad();
console.log(report.first.label);
console.log(report.steps.join(" | "));The first bad change is replace discount formula. In a real repository, git bisect automates this search across commits.
Which property should link a wrapper error to the original rejected error?
async function saveOrder() {
throw new Error("network down");
}
async function checkout() {
try {
await saveOrder();
return { ok: true };
} catch (error) {
throw new Error("checkout failed", { cause: error });
}
}
checkout().catch(error => console.log(error.cause.message));throw new Error("checkout failed", { cause: error });The cause property lets logs show both checkout failed and the original network down error.
Quiz: check your understanding
8 QUESTIONSThese questions mix tool choice with real output. Answer from evidence, not from the tool name alone.
Question 1 of 8Which DevTools action best matches a loop that only fails for
item.id === 42?Choose an answer to see the explanation.
Question 2 of 8What does the small conditional-break helper print?
Read the code, then predictconst items = [{ id: 17 }, { id: 42 }]; const hit = items.find(item => item.id === 42); console.log(hit.id);Choose an answer to see the explanation.
Question 3 of 8What does a logpoint do that
console.login source does not?Choose an answer to see the explanation.
Question 4 of 8What does this wrapper error code print?
Read the code, then predictconst networkError = new Error("request failed"); const checkoutError = new Error("checkout failed", { cause: networkError }); console.log(checkoutError.cause.message);Choose an answer to see the explanation.
Question 5 of 8Why can an async stack include
at async outerafter anawait?Choose an answer to see the explanation.
Question 6 of 8What should a production source map strategy avoid when maps contain private source?
Choose an answer to see the explanation.
Question 7 of 8Which command pauses a Node program before the first line so you can attach a debugger?
Choose an answer to see the explanation.
Question 8 of 8What does the third-click program print last?
Read the code, then predictlet clickCount = 0; function click() { clickCount += 1; return clickCount === 3 ? "submitted twice" : "waiting"; } click(); click(); console.log(click());Choose an answer to see the explanation.
Key takeaways
- Start with reproduce, isolate, hypothesize, verify; tools support the method.
- Conditional breakpoints, hit counts, logpoints, DOM breakpoints, event listener breakpoints, XHR/fetch breakpoints, and exception pause answer different questions.
- Async stack traces reconnect many
awaitchains, but stack strings are engine-specific evidence. - Source maps map generated code to authored code; keep production maps private when they expose source.
- Node debugging includes the inspector, VS Code,
node inspect, test debugging,NODE_DEBUG, and careful process-level error handling.
Remember the one-liner.
Advanced debugging is not more guessing; it is narrower evidence gathered with the least disruptive tool.
Up next: Creational patterns.