Master Chrome DevTools for JavaScript Debugging
Fix a real NaN bug and a real CPU freeze in a tiny demo app, using only the Sources, Network, and Performance panels.
What you'll build
A tiny "Load Products" page with two real bugs: one that shows $NaN instead of a price, and one that freezes the page for a beat. You'll fix both using nothing but Chrome DevTools, no console.log-and-pray debugging.
Prerequisites
- Google Chrome, current stable version (this works the same on macOS, Windows, and Linux)
- Python 3 installed (for a one-line local server), or Node.js 18+ if you'd rather use
npx serve - A text editor
- Basic familiarity with JavaScript syntax (functions, arrays,
fetch)
1. Set up the demo app
Create a folder and two files.
mkdir devtools-demo && cd devtools-demo
touch index.html app.js
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Product Catalog</title>
</head>
<body>
<button id="load-products">Load Products</button>
<pre id="output"></pre>
<script src="app.js"></script>
</body>
</html>
app.js:
const button = document.getElementById('load-products');
const output = document.getElementById('output');
async function fetchProducts() {
const res = await fetch('https://fakestoreapi.com/products?limit=5');
return res.json();
}
function bulkDiscount(price) {
let total = 0;
for (let i = 0; i < 5000; i++) {
for (let j = 0; j < 5000; j++) {
total += 1;
}
}
console.log('Bulk discount factor computed from', total, 'iterations');
return price * 0.9;
}
function applyDiscount(product) {
const discounted = bulkDiscount(product.pric);
return discounted;
}
button.addEventListener('click', async () => {
output.textContent = 'Loading...';
const products = await fetchProducts();
const rows = products.map((p) => `${p.title}: $${applyDiscount(p).toFixed(2)}`);
output.textContent = rows.join('\n');
});
Serve it locally:
python3 -m http.server 8000
Open http://localhost:8000 in Chrome and click Load Products. You'll see every price come back as $NaN. That's bug #1.
2. Set your first breakpoint
Open DevTools (Cmd+Option+I on Mac, F12 or Ctrl+Shift+I on Windows/Linux) and go to the Sources panel. In the file tree on the left, find app.js under localhost:8000. Locate the line:
const discounted = bulkDiscount(product.pric);
Click the line number in the gutter to set a breakpoint (it turns blue). Now click Load Products again. Execution pauses right on that line, and Chrome dims the rest of the page to signal you're in a paused state.
3. Read the scope and call stack
With execution paused, look at the right-hand panels:
- Scope shows local variables. Expand
productand you'll see it has apricefield but nopricfield, that's your bug: a typo. - Call Stack shows how you got here:
applyDiscountwas called from the anonymous arrow function inside.map(), which was called from theclickevent handler. Click each frame to jump the Scope panel to that context, useful for seeing whatproductslooked like before the map ran.
This is the whole point of the call stack: instead of guessing where a function got called from, you can literally walk backward through the invocation chain.
4. Fix the bug
Stop the debugger (click the blue "resume" play icon, or hit the deactivate-breakpoints toggle). In your editor, change:
const discounted = bulkDiscount(product.pric);
to:
const discounted = bulkDiscount(product.price);
Save the file. Refresh the browser tab first (Cmd+R / Ctrl+R) so Chrome loads the updated app.js, DevTools is still showing the stale cached version until you reload it. Once refreshed, go back to Sources, find the same line (now fixed), and set a fresh breakpoint there if you want to double-check the value. Click Load Products to trigger it, inspect product in Scope, and confirm price is a real number this time.
5. Throttle the network
Open the Network tab, click the throttling dropdown (defaults to "No throttling"), and select Slow 3G. Refresh the page and click Load Products again. Watch the waterfall, the products request now takes several seconds. This isolates network latency from the freeze you're about to chase next, it tells you the slowness you'll see in the next step is CPU-bound, not network-bound. Set throttling back to No throttling when you're done.
6. Profile the freeze
Open the Performance panel, click the record button (circle icon), click Load Products, wait for the page to respond, then click record again to stop.
In the flame chart, look at the main thread track. You'll see a single long Task block, roughly 200ms to 1s wide, and Chrome may flag it with a red triangle in the Summary as a Long Task. Zoom into that block and expand it: instead of one solid chunk, you'll find five consecutive bulkDiscount call blocks nested inside it, one per product from the .map() loop, each one running its 5,000 x 5,000 nested loop back to back on the main thread. That's the freeze, laid out call by call.
Notice we used console.log() inside bulkDiscount instead of alert(), an alert() would pause the entire page waiting on a dialog box, which wrecks a performance recording and makes it look like the freeze goes on forever. console.log() reports the same info without blocking anything.
The fix for production code would be moving that computation into a Web Worker or batching it with requestIdleCallback, out of scope for this tutorial, but now you know exactly where the time goes instead of guessing.
Verify it works
- Clicking Load Products shows real prices like
Fjallraven - Foldsack No. 1 Backpack: $98.96, noNaN. - The Performance recording shows one long Task block on the main thread containing five consecutive
bulkDiscountcalls, one per product, confirming the loop as the freeze's source. - Network panel, with throttling off, shows the fetch completing in well under a second.
Troubleshooting
| Problem | Fix |
|---|---|
Breakpoint doesn't hit after editing app.js |
You have to refresh the tab after saving, DevTools serves whatever's already loaded until you reload the page. |
| Fetch fails with a network error | Check your connection; fakestoreapi.com is a public demo API and occasionally has downtime, swap in any JSON endpoint with a price field. |
| Performance panel recording looks empty | Make sure you click record before clicking Load Products, and stop recording a second or two after the freeze ends. |
| Can't see variable values while paused | Expand Scope > Local in the Sources panel, it's collapsed by default. |
Next steps
Try conditional breakpoints (right-click a line number > "Add conditional breakpoint") to pause only when product.price is undefined. Then look into Chrome's Memory panel for heap snapshots, and read up on Web Workers for offloading exactly the kind of CPU-bound work you profiled here.
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 1
i'm skeptical that you can actually debug real cpu freezes without looking at the call stack or memory timeline. the performance panel alone is great for finding *where* the slowdown is, but figuring out *why* your event handlers are blocking the main thread usually means you need to dig into the stack traces. feels like this glosses over that part.