Tooling and debugging Core
Debugging tools
Pause a running program with breakpoints, step through it, watch values, inspect objects, debug Node.js and browser code, read network traffic and map compiled code back with source maps.
BY THE END OF THIS LESSON YOU CAN
- Pause a program at a breakpoint and step over, into and out of calls
- Drive the Node.js debugger from the terminal and attach Chrome DevTools or VS Code with --inspect
- Use conditional breakpoints, logpoints, watch expressions and pause-on-exception to answer one question at a time
- Inspect nested and secret-holding objects without leaking data into logs
- Diagnose a failing request from its status, headers and body in the Network panel or with curl
- Explain how a source map turns a compiled position back into a TypeScript line
A total of ₦17,650,001,500
A new checkout goes live, and the first test order shows this:
const settings = Object.fromEntries(
new URLSearchParams("deliveryFeeKobo=150000&freeDeliveryFromKobo=5000000"),
);
function cartSubtotal(lines) {
let subtotal = 0;
for (const line of lines) {
subtotal += line.priceKobo * line.quantity;
}
return subtotal;
}
function checkoutTotal(lines) {
const subtotal = cartSubtotal(lines);
const delivery = subtotal >= settings.freeDeliveryFromKobo ? 0 : settings.deliveryFeeKobo;
const total = subtotal + delivery;
return total;
}
const cart = [
{ sku: "RICE-5", priceKobo: 850000, quantity: 2 },
{ sku: "TOM-400", priceKobo: 65000, quantity: 1 },
];
console.log(`Total: ${checkoutTotal(cart)} kobo`);
await import("./checkout.js");
node run-checkout.jsTotal: 1765000150000 kobo
The settings come from a query string here; in a real server they would come from environment variables. The subtotal should be ₦17,650 plus ₦1,500 delivery. Instead, the total is a 13-digit number.
In The debugging method you reproduced bugs and tested hypotheses with console.log. That works, but every question costs an edit and a rerun, and you have to guess in advance which values you will want to see. A debugger is a tool that pauses a running program at a line you choose and lets you look at every value in scope, then move forward one step at a time. It does not replace the method; it makes the observe and test steps much faster.
This lesson covers the debugger in the terminal, in Chrome DevTools and in VS Code; the browser's debugger and Network panel; tools for printing objects; and source maps, which make all of these work on compiled TypeScript.
What a debugger does
Every debugger, in every language and editor, is built from the same few ideas:
| Term | Meaning |
|---|---|
| Breakpoint | A marker on a line. When execution reaches it, the program pauses before running that line. |
| Paused | The program is frozen, with all its variables alive. Timers, requests and other code wait too. |
| Call stack | The same list as a stack trace, but live: click a frame to see that function's variables. |
| Scope | The variables visible at the paused line: local, closure, module and global. |
| Continue | Run until the next breakpoint (or the end). |
| Step over | Run the current line, including any function it calls, and pause at the next line of the same function. |
| Step into | If the current line calls a function, go inside it and pause at its first line. |
| Step out | Run the rest of the current function and pause just after it returns to its caller. |
| Watch expression | An expression (subtotal, typeof delivery, cart.length) re-evaluated every time the program pauses. |
checkoutTotal(lines) cartSubtotal(lines)
───────────────────── ─────────────────────
▶ const subtotal = cartSubtotal(lines); ──step into──► let subtotal = 0;
for (const line of lines) {
│ step over subtotal += ...
▼ }
const delivery = ...; ◄──────step out────── return subtotal;
const total = subtotal + delivery;
Stepping is the automated version of the trace tables from Why doesn't this work?: the debugger fills in each row for you, and you keep doing the important part, predicting the next row before you press the key.
The debugger statement
You can also put a breakpoint in the code itself with the debugger statement. When a debugger is attached, execution pauses there. When none is attached, it does nothing at all:
function applyDelivery(subtotal, fee) {
debugger;
return subtotal + fee;
}
console.log(applyDelivery(1765000, 150000));
node debugger-statement.js and of the browser terminal1915000
It is handy for code that is hard to click on in an editor, such as code generated at runtime. Never commit one: a colleague who happens to have DevTools open will be stopped by it in the middle of their work. Most linters flag it (JavaScript tooling).
The Node.js debugger in the terminal
Node.js has a debugger built in. node inspect file.js starts your program paused on its first line and gives you a debug> prompt. It is the least comfortable debugger, but it works everywhere (including on a server over SSH), and because everything is typed, you can see exactly what each command does. The sessions below are real runs; your ws:// id will differ.
REASON IT OUT
Before you start the debugger
Using the method, decide what you want to learn before you pause anything:
- The output has far too many digits. What kind of bug produces a number that looks like two numbers written side by side?
- Which line is the first where that could happen? Which values on that line would you like to see, and what do you predict they are?
- Where should the breakpoint go so that you see those values before the damage happens?
Show the reasoning
The symptom: 1765000150000 is 1765000 followed by 150000. A number that looks like two numbers glued together is almost always string concatenation: + with a string on one side joins text instead of adding.
The line: const total = subtotal + delivery; is the only place where the two amounts meet. Prediction: one of them is a string. subtotal is built from numbers in the cart, so the suspect is delivery, which comes from the settings.
The breakpoint: at line 14, the start of checkoutTotal, so you can watch both values being made. A breakpoint at line 16 would also work, but starting one step earlier lets you check the subtotal too.
node inspect checkout.js < Debugger listening on ws://127.0.0.1:9229/b982a2ca-b8fa-4f6b-aa71-9e89a26cb1fe < For help, see: https://nodejs.org/learn/getting-started/debugging < connecting to 127.0.0.1:9229 ... ok < Debugger attached. < debug> Break on start in checkout.js:1 > 1 const settings = Object.fromEntries( 2 new URLSearchParams("deliveryFeeKobo=150000&freeDeliveryFromKobo=5000000"), 3 ); debug> sb(14) 9 } 10 return subtotal; 11 } 12 13 function checkoutTotal(lines) { >14 const subtotal = cartSubtotal(lines); 15 const delivery = subtotal >= settings.freeDeliveryFromKobo ? 0 : settings.deliveryFeeKobo; 16 const total = subtotal + delivery; 17 return total; 18 } 19 debug> c debug> break in checkout.js:14 12 13 function checkoutTotal(lines) { >14 const subtotal = cartSubtotal(lines); 15 const delivery = subtotal >= settings.freeDeliveryFromKobo ? 0 : settings.deliveryFeeKobo; 16 const total = subtotal + delivery; debug> s debug> step in checkout.js:6 4 5 function cartSubtotal(lines) { > 6 let subtotal = 0; 7 for (const line of lines) { 8 subtotal += line.priceKobo * line.quantity; debug> o debug> step in checkout.js:15 13 function checkoutTotal(lines) { *14 const subtotal = cartSubtotal(lines); >15 const delivery = subtotal >= settings.freeDeliveryFromKobo ? 0 : settings.deliveryFeeKobo; 16 const total = subtotal + delivery; 17 return total; debug> n debug> step in checkout.js:16 *14 const subtotal = cartSubtotal(lines); 15 const delivery = subtotal >= settings.freeDeliveryFromKobo ? 0 : settings.deliveryFeeKobo; >16 const total = subtotal + delivery; 17 return total; 18 } debug> exec delivery '150000' debug> exec typeof delivery 'string' debug> exec settings { deliveryFeeKobo: '150000', freeDeliveryFromKobo: '5000000' } debug> .exit
What happened, command by command:
sb(14)("set breakpoint") marked line 14;c("continue") ran until it was reached. The>shows the line about to run, the*a line with a breakpoint.s("step") went intocartSubtotal.o("out") ran the rest of it and came back tocheckoutTotal, now on line 15.n("next") stepped over line 15.exec exprevaluated expressions in the paused scope. The quotes in'150000'answer the question:deliveryis a string, and so is every value insettings.URLSearchParams(likeprocess.env, form fields and CSV cells) only ever produces text.
The hypothesis is confirmed in one pause, without a single edit. Notice what else the session tells you for free: freeDeliveryFromKobo is a string too. The comparison subtotal >= "5000000" happens to work, because >= converts the string to a number when the other side is a number. If both sides were strings, it would compare them alphabetically, and "900" >= "5000000" would be true. The fix is to convert the settings once, when they are loaded:
function loadSettings(query) {
const raw = Object.fromEntries(new URLSearchParams(query));
const settings = {};
for (const [key, value] of Object.entries(raw)) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0) throw new Error(`Setting ${key} must be whole kobo, got "${value}"`);
settings[key] = number;
}
return settings;
}
const settings = loadSettings("deliveryFeeKobo=150000&freeDeliveryFromKobo=5000000");
console.log(settings);
console.log(1765000 + settings.deliveryFeeKobo);
try {
loadSettings("deliveryFeeKobo=1,500");
} catch (error) {
console.log(error.message);
}
node settings-fixed.js and of the browser terminal{ deliveryFeeKobo: 150000, freeDeliveryFromKobo: 5000000 }
1915000
Setting deliveryFeeKobo must be whole kobo, got "1,500"Watch expressions
When you pause many times (a loop, a request handler), re-typing exec gets tedious. A watch expression is printed automatically at every pause:
node inspect checkout.js … debug> sb(8) 3 ); 4 5 function cartSubtotal(lines) { 6 let subtotal = 0; 7 for (const line of lines) { > 8 subtotal += line.priceKobo * line.quantity; 9 } 10 return subtotal; 11 } 12 13 function checkoutTotal(lines) { debug> c debug> break in checkout.js:8 6 let subtotal = 0; 7 for (const line of lines) { > 8 subtotal += line.priceKobo * line.quantity; 9 } 10 return subtotal; debug> watch('subtotal') debug> watch('line.sku') debug> c debug> break in checkout.js:8 Watchers: 0: subtotal = 1700000 1: line.sku = 'TOM-400' 6 let subtotal = 0; 7 for (const line of lines) { > 8 subtotal += line.priceKobo * line.quantity; 9 } 10 return subtotal; debug> c debug> < Total: 1765000150000 kobo < debug> < Waiting for the debugger to disconnect...
The second pause shows the loop's second iteration: the rice is already added (1,700,000 kobo), and the line about to be added is the tomato paste. Watches can be any expression, including typeof x, items.length or total > 10_000_000.
Conditional breakpoints
A breakpoint inside a loop that runs 10,000 times is useless if you care about one iteration. A conditional breakpoint only pauses when an expression is true:
node inspect checkout.js … debug> sb('checkout.js', 8, 'line.sku === "TOM-400"') 3 ); 4 5 function cartSubtotal(lines) { 6 let subtotal = 0; 7 for (const line of lines) { > 8 subtotal += line.priceKobo * line.quantity; 9 } 10 return subtotal; 11 } 12 13 function checkoutTotal(lines) { debug> c debug> break in checkout.js:8 6 let subtotal = 0; 7 for (const line of lines) { > 8 subtotal += line.priceKobo * line.quantity; 9 } 10 return subtotal; debug> exec line { sku: 'TOM-400', priceKobo: 65000, quantity: 1 } debug> exec subtotal 1700000
Good conditions come straight from your hypothesis: typeof subtotal !== "number", order.id === 1042, stock < 0. The program runs at full speed until the one moment you care about.
Pause on exceptions
For a crash, you want to be paused at the moment of the throw, with all the variables still alive. breakOnException does exactly that. Here it is on the receipt printer from the method lesson:
node inspect receipt.js … debug> breakOnException debug> c debug> < #1041 Ada: 1700000 kobo < debug> exception in receipt.js:8 6 function orderTotal(order) { 7 return order.items > 8 .map((item) => item.price * item.quantity) 9 .reduce((sum, n) => sum + n, 0); 10 } debug> exec order { id: 1042, customer: 'Bola', lineItems: Array(1) } debug> bt #0 orderTotal receipt.js:8:5 #1 receiptLine receipt.js:13:44 #2 (anonymous) receipt.js:17:14 #3 run node:internal/modules/esm/module_job:439:24
The stack trace told you where; the paused program tells you what: order 1042 has lineItems, not items. bt ("backtrace") prints the live call stack. breakOnException pauses on every thrown error, even ones that are caught later, which is noisy in code that uses exceptions a lot; breakOnUncaught pauses only on ones nobody catches, and breakOnNone turns both off.
Attaching Chrome DevTools or VS Code
The terminal debugger talks to Node.js through the inspector protocol, and so do the graphical debuggers. Start your program with one of these flags:
| Flag | What it does | Use it when |
|---|---|---|
--inspect | Runs normally, and accepts a debugger at any time | A server you want to attach to while it handles requests |
--inspect-brk | Pauses before the first line until a debugger attaches | A script that would finish (or crash) before you could attach |
--inspect-wait | Waits for a debugger, then runs without pausing on line 1 | Start-up code with breakpoints already set in your editor |
node --inspect-brk checkout.js Debugger listening on ws://127.0.0.1:9229/944a45cc-c8de-4d3c-8211-a0d29410ebc9 For help, see: https://nodejs.org/learn/getting-started/debugging
The program is now waiting. In Chrome, open chrome://inspect, find checkout.js under "Remote Target" and click inspect. A DevTools window opens on the Sources panel, paused on line 1. From here:
- Click a line number to set a breakpoint (a blue marker). Right-click it for Add conditional breakpoint or Add logpoint.
- The buttons at the top of the right-hand pane are continue (F8), step over (F10), step into (F11) and step out (Shift+F11).
- The Scope section shows local, closure, module and global variables. Hover any variable in the code to see its value. Private class fields (
#pin) are visible here, althoughconsole.lognever prints them. - The Watch section takes expressions; the Call Stack section lists frames, and clicking one shows its variables.
- The Console tab evaluates in the paused scope, like
exec. You can even change a variable (delivery = 150000) and continue, to test a fix without restarting.
VS Code
VS Code has the same debugger built in. The quickest way in: open the command palette, run Debug: JavaScript Debug Terminal, and start your program as usual (node checkout.js, npm run dev, npx tsx src/main.ts). Every Node.js process started from that terminal is attached automatically, and breakpoints you click in the editor gutter just work. For a repeatable setup, add a launch configuration:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug checkout",
"program": "${workspaceFolder}/checkout.js",
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "attach",
"name": "Attach to running server",
"port": 9229
}
]
}
skipFiles makes "step into" skip Node.js's own internals, so you never land in node:internal/…. Add "${workspaceFolder}/node_modules/**" to skip libraries as well. The second configuration attaches to a process you started yourself with --inspect. Press F5 to start the selected configuration; F9 toggles a breakpoint on the current line.
Logpoints
Both Chrome and VS Code offer logpoints: a breakpoint that does not pause, but prints a message such as subtotal={subtotal} delivery={delivery} every time the line runs. It is a console.log you add without editing, restarting or remembering to delete it afterwards. Reach for it when pausing would change the behaviour, for example in code with timeouts, where a paused program makes requests time out.
Never expose the inspector
Whoever connects to the inspector port can run any code inside your process: read secrets, change data, start other programs. By default Node.js only listens on127.0.0.1, which is safe. Never start a production server with --inspect=0.0.0.0, and never open port 9229 in a firewall. To debug a remote server, forward the port over SSH (ssh -L 9229:localhost:9229 server) and attach locally.Inspecting objects
Whether you print with console.log or pause in a debugger, the tool decides how much of an object you see. console.log in Node.js stops at a depth of 2 and prints [Object] below that. When the value you care about is deeper, ask for all of it:
import { inspect } from "node:util";
const order = {
id: 1042,
customer: { name: "Bola", address: { city: "Lagos", geo: { lat: 6.45, lng: 3.39 } } },
lines: [{ sku: "RICE-5", quantity: 2 }],
};
console.log(order);
console.dir(order, { depth: null });
console.log(inspect(order, { depth: 0 }));
node depth.js{
id: 1042,
customer: { name: 'Bola', address: { city: 'Lagos', geo: [Object] } },
lines: [ { sku: 'RICE-5', quantity: 2 } ]
}
{
id: 1042,
customer: {
name: 'Bola',
address: { city: 'Lagos', geo: { lat: 6.45, lng: 3.39 } }
},
lines: [ { sku: 'RICE-5', quantity: 2 } ]
}
{ id: 1042, customer: [Object], lines: [Array] }console.dir(value, { depth: null }) prints every level; util.inspect returns the same text as a string, with options for depth, sorting keys (sorted: true) and more. In a debugger, you expand levels by clicking instead.
Secrets in logs
Printing a whole object is convenient and dangerous. This account holds an API token:
import { inspect } from "node:util";
class Account {
#pin;
constructor(owner, balanceKobo, pin, apiToken) {
this.owner = owner;
this.balanceKobo = balanceKobo;
this.apiToken = apiToken;
this.#pin = pin;
}
}
const ada = new Account("Ada", 500000, "4321", "sk_test_51H8xQ2");
console.log(ada);
class SafeAccount extends Account {
[inspect.custom]() {
return `SafeAccount { owner: '${this.owner}', balanceKobo: ${this.balanceKobo}, apiToken: '[redacted]' }`;
}
}
console.log(new SafeAccount("Bola", 120000, "9999", "sk_test_77Zp01"));
node secrets.jsAccount {
owner: 'Ada',
balanceKobo: 500000,
apiToken: 'sk_test_51H8xQ2'
}
SafeAccount { owner: 'Bola', balanceKobo: 120000, apiToken: '[redacted]' }The private #pin is never printed, but the public apiToken is, and a debug log line like that, shipped to a log service, leaks the token to everyone who can read logs. util.inspect.custom (a symbol, Symbol.for("nodejs.util.inspect.custom")) lets a class decide how it is printed by console.log and util.inspect. Redacting sensitive fields there protects every log line at once. Logging with ZudoJS redacts by field name for the same reason. A debugger still shows the real values, which is fine: it runs on your machine, not in a log file.
The browser debugger
Everything above works in the browser too, with the same keys: open DevTools (F12), go to Sources, find your script, and click a line number. The browser adds breakpoints that have no line to click on, because browser code is driven by events:
| Breakpoint | Where | Answers |
|---|---|---|
| Event listener breakpoints | Sources → Event Listener Breakpoints → Mouse → click | "Which code runs when I click this?" |
| DOM change breakpoints | Elements → right-click a node → Break on → subtree modifications / attribute modifications / node removal | "Who keeps changing this element?" |
| XHR/fetch breakpoints | Sources → XHR/fetch Breakpoints → add a URL fragment | "Which code sends this request?" |
| Pause on exceptions | Sources → the pause-on-exceptions toggles | "What were the values when it threw?" |
The Elements panel also has an Event Listeners tab that lists every listener attached to the selected element, with a link to its source. It finds one of the most common browser bugs in seconds. Here is a cart button:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Cart</title></head>
<body>
<button id="add">Add rice to cart</button>
<p id="count">Cart: 0 items</p>
</body>
</html>
let items = 0;
const button = document.querySelector("#add");
const count = document.querySelector("#count");
function render() {
count.textContent = `Cart: ${items} items`;
button.addEventListener("click", () => {
items++;
render();
});
}
render();
for (let click = 1; click <= 3; click++) {
button.click();
console.log(`after click ${click}: ${count.textContent}`);
}
after click 1: Cart: 1 items after click 2: Cart: 3 items after click 3: Cart: 7 items
Each click adds more than the one before: 1, then 2 more, then 4 more. The Event Listeners tab would show the button with 8 click listeners after three clicks. The cause is in render: every render adds another listener, and they pile up. The fix is to attach listeners once, outside anything that runs repeatedly:
let items = 0;
const button = document.querySelector("#add");
const count = document.querySelector("#count");
function render() {
count.textContent = `Cart: ${items} items`;
}
button.addEventListener("click", () => {
items++;
render();
});
render();
for (let click = 1; click <= 3; click++) {
button.click();
console.log(`after click ${click}: ${count.textContent}`);
}
after click 1: Cart: 1 items after click 2: Cart: 2 items after click 3: Cart: 3 items
Events covers listeners and delegation in depth. The debugging lesson here is the symptom: when an action happens "too many times" and the count grows, look for a listener or a timer registered in code that runs more than once.
Inspecting network traffic
Many bugs are not in your code at all but in the conversation between two programs. This client fails with an error that seems to make no sense:
import { createServer } from "node:http";
const server = createServer((req, res) => {
if (req.method === "GET" && req.url === "/api/products") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify([{ sku: "RICE-5", priceKobo: 850000 }]));
return;
}
res.writeHead(404, { "content-type": "text/html" });
res.end("<!doctype html><h1>Page not found</h1>");
});
await new Promise((resolve) => server.listen(0, resolve));
const base = `http://localhost:${server.address().port}`;
async function loadProducts() {
const res = await fetch(`${base}/api/products/`);
return res.json();
}
try {
await loadProducts();
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
server.close();
node products-client.jsSyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON
"Unexpected token '<'" means the body starts with <, so it is HTML, not JSON. The error is thrown by res.json(), but the cause is the request. Look at the whole response instead of just the body:
import { createServer } from "node:http";
const server = createServer((req, res) => {
if (req.method === "GET" && req.url === "/api/products") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify([{ sku: "RICE-5", priceKobo: 850000 }]));
return;
}
res.writeHead(404, { "content-type": "text/html" });
res.end("<!doctype html><h1>Page not found</h1>");
});
await new Promise((resolve) => server.listen(0, resolve));
const base = `http://localhost:${server.address().port}`;
for (const path of ["/api/products/", "/api/products"]) {
const res = await fetch(base + path);
const body = await res.text();
console.log(`GET ${path} -> ${res.status} ${res.statusText}`);
console.log(` content-type: ${res.headers.get("content-type")}`);
console.log(` body: ${body.slice(0, 40)}`);
}
server.close();
node products-inspect.jsGET /api/products/ -> 404 Not Found
content-type: text/html
body: <!doctype html><h1>Page not found</h1>
GET /api/products -> 200 OK
content-type: application/json
body: [{"sku":"RICE-5","priceKobo":850000}]Status 404 and text/html: the trailing slash asked for a route that does not exist, and the server answered with its HTML error page. The rule for any failed request: check the status, then the content type, then the body, before you debug the code that reads them. A client should also check res.ok before calling res.json(), so that the error names the status instead of complaining about a <.
The Network panel
In the browser, DevTools' Network panel records every request the page makes. Click a request to see:
- Headers: the URL, method, status code, request headers (was the
Authorizationheader sent? which cookies?) and response headers (content-type, CORS headers,cache-control). - Payload: what the page sent. Is the body the JSON you think it is, or
[object Object]? - Preview / Response: what came back, before your code touched it.
- Timing: where the time went: waiting for a connection, waiting for the server (a slow query), or downloading.
Useful switches: Preserve log keeps requests across page reloads and redirects; Disable cache makes sure you see what the server sends now; the throttling menu simulates a slow phone connection, which exposes race conditions and missing loading states. A red (failed) or CORS error status means the browser blocked the request; the reason is in the Console (Networking from JavaScript explains CORS).
Right-click a request and choose Copy → Copy as cURL to get a command that repeats the exact request from a terminal. That is how you turn a browser bug into a reproduction anyone can run:
curl -i http://localhost:3457/api/products/ HTTP/1.1 404 Not Found content-type: text/html Date: Thu, 24 Sep 2026 20:33:03 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked <!doctype html><h1>Page not found</h1>
-i prints the status line and headers before the body. For Node.js code, Node 24 can also show its own fetch and http requests in the DevTools Network panel: start it with node --inspect --experimental-network-inspection app.js and attach as above. The flag is still experimental, so logging the status, content type and the start of the body, as above, remains the dependable way.
Source maps
You rarely run the code you write. TypeScript is compiled to JavaScript; browser code is bundled and minified into one file with short variable names. A stack trace or breakpoint in the output points at code you never wrote. The TypeScript compiler lesson introduced source maps; here is what they do for debugging. A wallet transfer in TypeScript:
interface Account {
owner: string;
balanceKobo: number;
}
type Transfer = { from: Account; to: Account; amountKobo: number };
export function transfer({ from, to, amountKobo }: Transfer): void {
if (amountKobo <= 0) {
throw new RangeError(`Transfer amount must be positive, got ${amountKobo}`);
}
from.balanceKobo -= amountKobo;
to.balanceKobo += amountKobo;
}
import { transfer } from "./transfer.js";
const ada = { owner: "Ada", balanceKobo: 500000 };
const bola = { owner: "Bola", balanceKobo: 0 };
transfer({ from: ada, to: bola, amountKobo: 200000 });
transfer({ from: ada, to: bola, amountKobo: -50000 });
With "sourceMap": true, "rootDir": "src" and "outDir": "dist" in tsconfig.json, tsc writes a .js.map next to every .js file, and a comment at the end of the JavaScript that points to it:
npx tsc ls dist main.js main.js.map transfer.js transfer.js.map tail -n 1 dist/transfer.js //# sourceMappingURL=transfer.js.map cat dist/transfer.js.map {"version":3,"file":"transfer.js","sourceRoot":"","sources":["../src/transfer.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,QAAQ,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,UAAU,EAAY;IACzD,IAAI,UAAU,IAAI,CAAC,EAAE,CAAC;QACpB,MAAM,IAAI,UAAU,CAAC,yCAAyC,UAAU,EAAE,CAAC,CAAC;IAC9E,CAAC;IACD,IAAI,CAAC,WAAW,IAAI,UAAU,CAAC;IAC/B,EAAE,CAAC,WAAW,IAAI,UAAU,CAAC;AAC/B,CAAC"}
Run the compiled code, first without the map and then with it:
node dist/main.js file:///home/you/wallet/dist/transfer.js:3 throw new RangeError(`Transfer amount must be positive, got ${amountKobo}`); ^ RangeError: Transfer amount must be positive, got -50000 at transfer (file:///home/you/wallet/dist/transfer.js:3:15) at file:///home/you/wallet/dist/main.js:5:1 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:643:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.19.0 node --enable-source-maps dist/main.js /home/you/wallet/src/transfer.ts:10 throw new RangeError(`Transfer amount must be positive, got ${amountKobo}`); ^ RangeError: Transfer amount must be positive, got -50000 at transfer (/home/you/wallet/src/transfer.ts:10:11) at <anonymous> (/home/you/wallet/src/main.ts:7:1) at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:643:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.19.0
Without the map, the trace names transfer.js:3 and main.js:5: lines in files you never edit, whose numbers do not match your source (the interface and type alias were erased, so everything moved up). With --enable-source-maps, the same error names transfer.ts:10 and main.ts:7, the second transfer call, and even shows the TypeScript line.
What is inside the map
The mappings field is a compact list of pairs: "this position in the output came from that position in the source". Lines of output are separated by ;, positions within a line by ,, and each position is a few numbers written in base64 VLQ, a variable-length encoding where each character carries 5 bits of a number plus a "more digits follow" bit. Each number is stored as the difference from the previous one, which keeps the map small. Decoding it takes twenty lines:
const map = {
sources: ["../src/transfer.ts"],
mappings:
"AAOA,MAAM,UAAU,QAAQ,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,UAAU,EAAY;IACzD,IAAI,UAAU,IAAI,CAAC,EAAE,CAAC;QACpB,MAAM,IAAI,UAAU,CAAC,yCAAyC,UAAU,EAAE,CAAC,CAAC;IAC9E,CAAC;IACD,IAAI,CAAC,WAAW,IAAI,UAAU,CAAC;IAC/B,EAAE,CAAC,WAAW,IAAI,UAAU,CAAC;AAC/B,CAAC",
};
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function decodeVlq(segment) {
const values = [];
let value = 0;
let shift = 0;
for (const char of segment) {
const digit = B64.indexOf(char);
value += (digit & 31) << shift;
if (digit & 32) {
shift += 5;
continue;
}
values.push(value & 1 ? -(value >> 1) : value >> 1);
value = 0;
shift = 0;
}
return values;
}
let sourceLine = 0;
let sourceColumn = 0;
map.mappings.split(";").forEach((line, jsLine) => {
let jsColumn = 0;
line.split(",").forEach((segment, i) => {
const [dJsColumn, , dSourceLine, dSourceColumn] = decodeVlq(segment);
jsColumn += dJsColumn;
sourceLine += dSourceLine;
sourceColumn += dSourceColumn;
if (i === 0) console.log(`transfer.js ${jsLine + 1}:${jsColumn + 1} -> transfer.ts ${sourceLine + 1}:${sourceColumn + 1}`);
});
});
node read-map.js and of the browser terminaltransfer.js 1:1 -> transfer.ts 8:1 transfer.js 2:5 -> transfer.ts 9:3 transfer.js 3:9 -> transfer.ts 10:5 transfer.js 4:5 -> transfer.ts 11:3 transfer.js 5:5 -> transfer.ts 12:3 transfer.js 6:5 -> transfer.ts 13:3 transfer.js 7:1 -> transfer.ts 14:1
Line 3 of the JavaScript (the throw) comes from line 10 of the TypeScript: exactly the translation --enable-source-maps did for the stack trace. Debuggers do the same for breakpoints, in both directions: you click line 10 of transfer.ts in VS Code, and it sets the real breakpoint on line 3 of transfer.js.
Source maps in practice
- Node.js: run compiled code with
--enable-source-maps(orNODE_OPTIONS=--enable-source-maps) so production logs show.tslines.tsxand Node's own type stripping need no maps at all: type stripping replaces the types with spaces, so every line and column stays where it was. - Browsers: DevTools loads maps automatically when the
sourceMappingURLcomment points to one, and shows your original files in Sources. Bundlers (Vite, esbuild, webpack) generate them with an option. - Inline maps (
"inlineSourceMap": true) embed the map in the.jsfile as base64. That is one file fewer to lose, and a bigger file. - Publishing maps is publishing your source. A public site that serves
.mapfiles lets anyone read your original code with comments. That is fine for open-source projects; others upload maps privately to their error-tracking service and do not serve them. - A stale map lies. If the
.jsis rebuilt but the.mapis not (or the other way round), traces point at the wrong lines. When a trace names a line that cannot possibly throw, rebuild before you debug.
Choosing the tool
A tool only helps when it answers the question your hypothesis asks. A quick guide:
| Question | Tool |
|---|---|
| What are all the values at this line? | Breakpoint, then Scope / exec |
| How does this value change over a loop? | Watch expressions, or a logpoint |
| What is different in iteration 7,431? | Conditional breakpoint |
| What was the state when it threw? | Pause on exceptions |
| Who calls this function with a bad value? | Breakpoint + Call Stack, or console.trace |
| Which code changed this element / ran on this click? | DOM change and event listener breakpoints |
| Did the request go out, and what came back? | Network panel, curl -i, logging status and content type |
| Where is this line in my TypeScript? | Source maps, --enable-source-maps |
| What happened in production last night? | Structured logs: you cannot pause the past |
| Why is it slow? | A profiler: node --cpu-prof, or the DevTools Performance panel |
A few more Node.js flags are worth knowing: --trace-warnings adds a stack trace to warnings (such as "MaxListenersExceededWarning", a sign of the listener pile-up above), --trace-uncaught shows where a non-Error value was thrown in CommonJS code, and --stack-trace-limit=50 keeps more frames.
Practice
TRY IT YOURSELF
Plan a debugging session
A loop applies a discount to 20,000 orders, and one order ends up with a negative total. Write down, in node inspect commands, the session you would run: where the breakpoint goes, its condition, and the two things you would evaluate once it pauses. Assume the loop body is on line 42 of discounts.js and each order has id, totalKobo and discountKobo.
Show a solution
Pause just before the subtraction, only for the order that goes wrong, and look at both inputs:
$ node inspect discounts.js
debug> sb('discounts.js', 42, 'order.discountKobo > order.totalKobo')
debug> c
debug> exec order
debug> exec typeof order.discountKobo
debug> bt
The condition comes from the hypothesis "the discount is larger than the total". If it never pauses, that hypothesis is dead: try order.totalKobo - order.discountKobo < 0 or typeof order.discountKobo !== "number" next. bt shows which code built the discount, one frame down.
TRY IT YOURSELF
Redact before you log
A Customer class has name, email and cardNumber. Give it a custom inspect method so that console.log shows the name, the email and only the last four digits of the card.
Show a solution
import { inspect } from "node:util";
class Customer {
constructor(name, email, cardNumber) {
this.name = name;
this.email = email;
this.cardNumber = cardNumber;
}
[inspect.custom]() {
return `Customer { name: '${this.name}', email: '${this.email}', card: '**** ${this.cardNumber.slice(-4)}' }`;
}
}
console.log(new Customer("Chidi", "chidi@example.com", "4242424242424242"));
node customer-redact.jsCustomer { name: 'Chidi', email: 'chidi@example.com', card: '**** 4242' }Every console.log and util.inspect of a customer is now safe, including logs you have not written yet. JSON.stringify ignores the inspect method, so for JSON logs add a toJSON() method with the same redaction.
TRY IT YOURSELF
Read the response first
A page shows "SyntaxError: Unexpected end of JSON input" when it saves a task. In the Network panel the POST /api/tasks request has status 204 No Content. What is the bug, and how should the client code change?
Show a solution
204 means "success, and there is no body". The client calls res.json() on an empty body, which throws "Unexpected end of JSON input". The request worked; the client's assumption did not. Check the status (and content-type) before parsing:
async function readJson(res) {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
if (res.status === 204) return null;
return res.json();
}
const saved = await readJson(new Response(null, { status: 204 }));
console.log(saved);
const task = await readJson(new Response('{"id":7,"title":"Pay rent"}', { status: 201 }));
console.log(task);
try {
await new Response(null, { status: 204 }).json();
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
node no-content.js and of the browser terminalnull
{ id: 7, title: 'Pay rent' }
SyntaxError: Unexpected end of JSON inputnew Response(...) builds a response by hand, which makes this easy to test without a server.
Summary
- A debugger pauses at a breakpoint and shows every value in scope. Step over runs a line, step into enters a call, step out finishes the current function; watch expressions are re-evaluated at every pause.
node inspectworks anywhere;--inspect,--inspect-brkand--inspect-waitlet Chrome DevTools (chrome://inspect) or VS Code attach. The JavaScript Debug Terminal in VS Code attaches automatically. Never expose the inspector port.- Conditional breakpoints, logpoints and pause-on-exceptions let you answer one precise question without editing code.
console.dir(x, { depth: null })prints nested objects in full;util.inspect.customcontrols how an object is printed and keeps secrets out of logs.- In the browser, event listener, DOM change and fetch breakpoints find the code behind an event; the Event Listeners tab finds duplicate listeners.
- For a failed request, check the status, then the content type, then the body. The Network panel and
curl -ishow all three; "Copy as cURL" turns a browser request into a reproduction. - Source maps translate positions in compiled code back to your source. Run compiled code with
--enable-source-maps, and rebuild when a trace points somewhere impossible.
Next: Debugging practice, where you find a regression with git bisect, dig to a root cause with the five whys, and work through five complete bug hunts.