Reading about DevTools is like reading about swimming. The only thing that actually moves the needle is doing the moves while the page is broken. So instead of another survey post, this is a hands-on lab. You’ll save one HTML file, open it in Chrome, and run twelve experiments. Each one starts with a prediction, walks through the moves, and ends with a result you can verify yourself.
Block out an hour. Have Chrome open. Have one terminal open for curl. Don’t skip the predictions — that’s where the learning lives.
What you need
- Chrome (or any Chromium browser — Edge, Brave, Arc all work)
- This MDX page in one tab, the lab page in another
- A keyboard. Mouse-driven debugging is twice as slow.
Step 0 · Save the lab
Copy this into a file called lab.html anywhere on your disk, then open it in Chrome (File → Open File… or just drag it into the address bar).
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>DevTools Lab</title>
<style>
body {
font: 16px/1.5 system-ui, sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 24px;
color: #1a202c;
}
h1 { font-size: 24px; }
section { margin: 32px 0; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; }
button {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid #4f46e5;
background: #4f46e5;
color: white;
cursor: pointer;
font-weight: 600;
}
button:hover { background: #4338ca; }
.row { display: flex; gap: 12px; align-items: center; }
#counter { font-size: 32px; font-weight: 800; color: #4f46e5; }
#log { margin-top: 12px; padding: 12px; background: #f7fafc; border-radius: 8px; font-family: monospace; font-size: 13px; max-height: 160px; overflow: auto; }
#disappearing { padding: 8px 12px; background: #fef3c7; border-radius: 6px; display: inline-block; }
.ghost { transition: opacity 600ms; }
.ghost.gone { opacity: 0; }
</style>
</head>
<body>
<h1>DevTools Lab</h1>
<p>Open DevTools (<kbd>F12</kbd>) and follow the article.</p>
<section id="lab-counter">
<h2>Counter</h2>
<div class="row">
<button id="inc">+1</button>
<button id="loop">Run loop (200×)</button>
<span id="counter">0</span>
</div>
<div id="log"></div>
</section>
<section id="lab-dom">
<h2>Vanishing element</h2>
<span id="disappearing" class="ghost">I will be removed.</span>
<div style="margin-top:12px">
<button id="kill">Remove it</button>
<button id="kill-soon">Remove in 3s</button>
</div>
</section>
<section id="lab-net">
<h2>Network demo</h2>
<button id="fetch-cat">Fetch a cat fact</button>
<pre id="cat-output" style="margin-top:12px;background:#f7fafc;padding:12px;border-radius:8px;min-height:48px"></pre>
</section>
<section id="lab-event">
<h2>Mystery click</h2>
<button id="mystery">Click me — what fires?</button>
<p id="mystery-out"></p>
</section>
<script>
const $ = (s) => document.querySelector(s);
let count = 0;
function setCount(n) {
count = n;
$('#counter').textContent = String(n);
}
$('#inc').addEventListener('click', () => setCount(count + 1));
$('#loop').addEventListener('click', () => {
const log = $('#log');
log.textContent = '';
for (let i = 0; i < 200; i++) {
const odd = i % 2 === 1;
// Subtle bug: log every odd i, but on i === 137 we set a "bad" value
const value = i === 137 ? 'BAD' : i;
log.textContent += `i=${i} odd=${odd} value=${value}\n`;
}
setCount(count + 200);
});
$('#kill').addEventListener('click', () => $('#disappearing')?.remove());
$('#kill-soon').addEventListener('click', () => {
setTimeout(() => $('#disappearing')?.remove(), 3000);
});
$('#fetch-cat').addEventListener('click', async () => {
const out = $('#cat-output');
out.textContent = 'Loading…';
try {
const r = await fetch('https://catfact.ninja/fact');
const j = await r.json();
out.textContent = j.fact;
} catch (e) {
out.textContent = 'Error: ' + e.message;
}
});
// Three handlers fight over the same button — which one runs?
const btn = $('#mystery');
btn.addEventListener('click', () => $('#mystery-out').textContent = 'Handler A ran');
btn.addEventListener('click', () => $('#mystery-out').textContent += ' · B ran');
document.addEventListener('click', (e) => {
if (e.target === btn) $('#mystery-out').textContent += ' · doc ran';
}, true); // capture phase
</script>
</body>
</html>
Got it open? Good. Twelve experiments below. Run each. Predict before peeking.
Experiment 1 · Pick an element with one keystroke
Prediction. Without any panels open, how would you inspect the +1 button?
Move. With the lab page focused, press Ctrl/Cmd+Shift+C. Your cursor turns into a picker. Click the +1 button.
Expected. DevTools opens, jumps to Elements, and selects the <button id="inc"> node. The Styles panel shows everything that applies.
Why it matters. Three keys replace: open DevTools → click Elements → manually scroll the tree → find your node. Twenty seconds → one second.
Bonus. Select the picker again, hover (don’t click) over the counter <span>. The viewport overlay shows margin/padding/box dimensions live. That overlay is the secret weapon for “why is there random space here?”
Experiment 2 · Edit production CSS in 5 seconds
Prediction. Can you change the button color, see it live, and discard the change with one undo?
Move. With <button id="inc"> selected, in the Styles panel find the background: #4f46e5; rule. Click the colored swatch — a color picker opens. Drag it to red. Hit Esc when done.
Expected. The button is red on the live page. Reload — back to indigo. Nothing was saved.
Why it matters. This is how to test a CSS fix without leaving the browser. The Styles tab is a sandbox: the page reloads wipe it. When you find the right value, copy the rule into your source.
Bonus. Click :hov (next to :cls) and tick :hover. The button now displays its hover state without your mouse on it. Now check Computed → background-color. Edit hover styles without playing whack-a-mole with your cursor.
Experiment 3 · console.table makes data readable
Prediction. Array.from({length:8}, (_,i) => ({i, sq:i*i, even:i%2===0})) — easier to read as a list, or as a table?
Move. Open the Console (Esc toggles the drawer). Paste:
console.table(Array.from({length: 8}, (_, i) => ({ i, sq: i * i, even: i % 2 === 0 })));
Expected. A real table with sortable columns. Click the sq header to sort.
Why it matters. Fifteen objects in console.log is unreadable. The same fifteen in console.table are scannable in two seconds. Use it for arrays of API rows, query plans, anything tabular.
Bonus. console.table(elements, ['id', 'tagName', 'className']) — second arg picks columns. Try console.table(document.querySelectorAll('button'), ['id', 'textContent']).
Experiment 4 · $0 and friends
Prediction. Instead of typing document.querySelector('#counter'), what does DevTools give you for free?
Move. In the Elements tab, click the <span id="counter"> so it’s selected (highlighted). Switch to Console. Type:
$0
$0.getBoundingClientRect()
$0.style.color = 'crimson'
Expected. $0 is the currently selected node. The bounding-rect call returns coords. The color line turns the counter red.
Why it matters. $0 through $4 are the last five elements you selected — saves a querySelector call every time you debug DOM. $$('button') returns all buttons as a real array (unlike the live NodeList).
Experiment 5 · Conditional breakpoint instead of console.log in a loop
Prediction. The Run loop (200×) button has a hidden bug where one iteration produces value=BAD. Find the iteration without scrolling 200 lines.
Move.
- Open Sources, find the inline
<script>oflab.html. (It’ll be under(no domain)→lab.html.) - Find the line
const value = i === 137 ? 'BAD' : i;. Right-click the line number → Add conditional breakpoint. - Type
value === 'BAD'and hit Enter. - Click Run loop (200×) in the page.
Expected. Execution pauses on that line, exactly when i === 137. Hover i, hover value, see the runtime values. Hit F8 to resume.
Why it matters. This replaces the entire if (i === 137) console.log(...) workflow. No edits to source, no rebuild, no commit-before-pushing-clean. The breakpoint is in DevTools, not in your code.
Bonus. Right-click the same line → Add logpoint → type 'iter ' + i + ' = ' + value. Run again. You get a console.log for every iteration without modifying source. Logpoints survive reloads. Set them, forget them, delete in DevTools when done.
Experiment 6 · Catch what’s deleting your node (DOM breakpoint)
Prediction. The “I will be removed” element disappears when you click Remove it. Without searching code, how do you find the line that did it?
Move.
- In Elements, right-click
<span id="disappearing">→ Break on → Node removal. - Click Remove it on the page.
Expected. The page freezes. Sources panel jumps to $('#disappearing')?.remove(). The call stack shows the click handler that ran it. F8 to resume.
Why it matters. When something keeps wiping an element (“the modal closes itself,” “my React node is unmounting”), DOM breakpoints find the culprit in seconds. There are three flavors: subtree modifications, attribute modifications, node removal — pick the one that matches your symptom.
Bonus. Try Remove in 3s — the breakpoint still catches the deferred removal three seconds later. The call stack shows you the setTimeout callback that triggered it.
Experiment 7 · XHR/fetch breakpoint — find the request’s origin
Prediction. When you click Fetch a cat fact, somewhere in code a fetch() is called. How do you pause at the exact line that fired it?
Move.
- Open Sources → side panel → XHR/fetch Breakpoints → click
+→ typecatfact. - Click Fetch a cat fact.
Expected. Execution pauses just before the fetch is sent. The call stack shows the click handler that called it.
Why it matters. “Where does this random API call come from?” used to mean grepping a 5,000-file codebase. Now it’s two seconds. Pair with conditional breakpoints to catch only specific URLs.
Experiment 8 · Replay a request as fetch (or curl)
Prediction. Once a request fires, can you re-issue it from your terminal without rebuilding the headers by hand?
Move.
- Open Network, click Fetch a cat fact so the request appears.
- Right-click the
factrequest → Copy → Copy as cURL (or Copy as fetch). - Paste into your terminal:
curl 'https://catfact.ninja/fact' \
-H 'accept: */*' \
-H 'origin: null' \
...
Expected. A working curl you can re-run, modify, and pipe into jq. Same for “Copy as fetch” → paste into the Console and modify the body.
Why it matters. Reproducing a real request — auth headers and all — used to take 10 minutes of Postman. Now it’s 10 seconds. Pair with Edit and Replay (right-click → Replay XHR with edits) for fast API iteration.
Experiment 9 · Event listener breakpoint — find the handler that ate the click
Prediction. The Click me button is wired up to multiple handlers. Which runs first?
Move.
- Sources → side panel → Event Listener Breakpoints → expand Mouse → tick click.
- Click Click me.
Expected. Execution pauses inside the capture-phase document handler (because capture runs first). F8 resumes; pause again on each subsequent listener (the two button handlers).
Why it matters. When a third-party library is “swallowing” your event (preventDefault / stopPropagation), event-listener breakpoints walk you through every handler in dispatch order. You see exactly which library’s code is on the call stack.
Bonus. Click Mystery click without breakpoints, then in Console type getEventListeners(document.querySelector('#mystery')). DevTools returns every listener attached to that element, with the handler functions exposed. monitorEvents($0, 'click') logs every event — opposite trade-off, useful when breakpoints would interrupt flow.
Experiment 10 · Live expression — keep an eye on document.activeElement
Prediction. When debugging keyboard navigation, where is focus right now? Live expressions answer this without polling.
Move.
- Console → click the eye icon (top-right) → type
document.activeElement.id || document.activeElement.tagName. Hit Enter. - Click around the lab page. Tab between buttons.
Expected. The expression value updates on every tick. As you Tab, you see inc → loop → kill → kill-soon → fetch-cat → mystery.
Why it matters. Live expressions are pinned console.logs that refresh themselves. Pin performance.memory.usedJSHeapSize, pin window.location.hash, pin anything you’re watching. Saves 200 manual evaluations.
Experiment 11 · Performance — find the long task
Prediction. When you click Run loop (200×), can you see the JS work as a flame chart and find the function name responsible?
Move.
- Open Performance panel → click the round record button (or Ctrl+E).
- Click Run loop (200×) in the page.
- Stop recording.
Expected. A flame chart. The yellow blocks under “Main” are JS execution. Zoom into the area where the loop ran (use mouse wheel + drag). You’ll see the click handler, then 200 iterations of string concatenation, then the DOM write at setCount.
Look for:
- Long task indicators (red triangles in the top track) — any task > 50ms blocks user input.
- Bottom-Up view (panel at the bottom) sorted by Self Time — surfaces the function eating the most time.
- Scripting (yellow) dominating means JS work is the bottleneck.
Bonus. Re-record after wrapping the loop in requestIdleCallback. Same work, no long task — the browser slices it across idle frames. The flame chart proves it.
Experiment 12 · Paint flashing and layout shifts
Prediction. When the counter ticks up, what part of the page actually repaints?
Move.
- Ctrl+Shift+P → type “Show Rendering” → Enter.
- In the Rendering panel, tick Paint flashing.
- Click
+1a few times.
Expected. Only the counter text flashes green — the rest of the page stays inert. That’s good: only the changed pixels repaint.
Now try this in the Console:
document.body.style.transition = 'background 0.5s';
document.body.style.background = '#fef3c7';
The whole page flashes green — the entire body repainted. That’s the kind of overdraw that makes pages feel laggy.
Bonus toggles in the same panel:
- Layout Shift Regions — highlights any element that caused CLS. Run it on a real news site that loads ads after first paint; the flashing tells you exactly what jumped.
- FPS meter — overlays current frame rate. Leave it on while scrolling a long list.
- Frame Rendering Stats — adds GPU memory, dropped-frame counts.
Bonus rounds (skim or skip)
Network throttling. Network panel → “No throttling” dropdown → Slow 3G. Reload the lab page. Loading states that looked instant on localhost now show their seams. Test your skeletons here, not on real users.
Application panel surgery. Application → Cookies → click any cookie value → edit inline. Set HttpOnly off, set SameSite=None. This is the fastest way to reproduce auth bugs that only happen in specific cookie configs. The same panel has a Clear site data button — nuclear option for “the bug might be stale state.”
Workspace. Sources → Filesystem → “Add folder to workspace.” Drag your local project folder in. Now CSS edits in DevTools save back to disk. You can ship a real fix without touching your editor — and reload to verify nothing reverted.
debugger; everywhere. Drop a debugger; line in source. DevTools pauses there as long as it’s open. Faster than navigating Sources for a one-off pause. Just remember to delete before committing — every senior engineer has shipped a debugger; to staging at some point.
What to take from the lab
If you ran all twelve, you’ve now used: element picker, live style edits, table-formatted logs, $0, conditional breakpoints, logpoints, DOM breakpoints, XHR breakpoints, event-listener breakpoints, live expressions, performance recording, paint flashing. That’s roughly 80% of what separates an hour-long debugging session from a five-minute one.
A short list to pin to the corkboard:
| When you see… | Reach for… |
|---|---|
| ”Why isn’t this style applying?” | Computed tab |
| ”This breaks on the Nth iteration” | Conditional breakpoint |
| ”Something keeps removing this element” | DOM breakpoint → node removal |
| ”Where does this random API call come from?” | XHR/fetch breakpoint |
| ”Which library swallowed my click?” | Event listener breakpoint |
| ”The page feels slow” | Performance → record → Bottom-Up |
| ”The whole page flashes when state changes” | Rendering → Paint flashing |
| ”I need to repro this request in my terminal” | Network → Copy as cURL |
| ”I want to watch this value tick” | Console → Live expression |
The microscope is just a tool. The instinct to form a hypothesis, run an experiment, read the result, refine is what makes this stuff fast. Run these twelve once now, and the next time a real bug shows up the right move will already be in your fingers.
Where to go next
The official docs at developer.chrome.com/docs/devtools are surprisingly good — better than most blog posts (this one included) — and the “What’s New in DevTools” releases every six weeks are worth a five-minute skim. Logpoints, copy-as-fetch, and the AI assistance panel all quietly appeared there first.
For framework-specific work, install React DevTools, Vue DevTools, or Redux DevTools as Chrome extensions. They mount as additional panels and surface props/state/profiling in framework-native terms — but everything you learned here works underneath.
Now close this tab. Open lab.html. Open DevTools. Run experiment 1.