Chapter 5 — The Browser as a Debugger
DevTools, Used With Intent
Part 2: Instruments. Prerequisites: Chapters 1–4.
Opening
Every frontend engineer opens DevTools. Few use more than a tenth of it. The usual pattern is: open the Console, look for red, add console.log, refresh, repeat.
DevTools is not a log viewer. It is a set of instruments, and each one answers a different question. The Network panel answers "what did the browser actually send and receive?" The Sources panel answers "what is this code doing, line by line, right now?" The Application panel answers "what state is the browser holding that I can't see?" When you know which question you are asking, you know which panel to open — and you stop guessing.
This chapter walks through DevTools the way a senior engineer uses it during an investigation. It uses Chrome's names and shortcuts; Firefox and Safari have the same instruments under slightly different names.
Part 1: The Map — Which Panel Answers Which Question
Open DevTools with Cmd+Option+I (Mac) or Ctrl+Shift+I (Windows/Linux). Learn one more shortcut immediately: Cmd/Ctrl+Shift+P opens the Command Menu, which can run any DevTools feature by name — you never need to hunt through menus.
| Panel | The question it answers |
|---|---|
| Console | What errors and warnings happened? What is the value of this expression in the page, right now? |
| Network | What requests did the page make, what exactly was sent and received, and how long did each part take? |
| Sources | What is the code doing, step by step? Where can I pause it? |
| Application | What is stored — cookies, local storage, IndexedDB, caches, service workers? |
| Elements | What is the DOM and CSS right now, and why does this element look like this? |
| Performance | What did the main thread spend its time on? |
| Memory | What objects are alive, and why? (Covered in depth in FE-07.) |
The rule of thumb for an investigation: Console first, Network second, then whichever panel the evidence points to.
Part 2: The Network Panel — What Actually Went Over the Wire
Most frontend bugs are really "the data was not what I expected" bugs. The Network panel is the ground truth: not what your code meant to send, but what the browser actually sent and received.
Set it up before you reproduce
Three settings at the top of the panel change what you can see:
- Preserve log — keep requests across page navigations and reloads. Without it, a login redirect or a form submission that navigates away wipes the evidence. Turn it on whenever a bug involves a redirect or navigation.
- Disable cache — bypass the browser's HTTP cache while DevTools is open. Use it to rule out stale cached responses. (It does not bypass a service worker — see Part 5.)
- Throttling — simulate a slow or offline network (
Fast 4G,Slow 4G,Offline). Many bugs only appear when responses are slow: race conditions, double submissions, loading states that never resolve.
Filter instead of scrolling
The type buttons (Fetch/XHR, Doc, JS, CSS, Img, WS) narrow by kind. The filter box also accepts properties, which is much faster than scrolling:
| Filter | Shows |
|---|---|
status-code:500 | Only responses with that status |
method:POST | Only POST requests |
domain:api.example.com | Only requests to that host |
larger-than:500k | Only large responses |
-domain:analytics.example.com | Everything except that host (a leading - negates) |
is:running | Requests still in progress |
Read the columns
| Column | What to look for |
|---|---|
| Status | The status code — or (failed), (canceled), (blocked:…), (pending). Chapter 4 decodes all of these |
| Initiator | Which code started this request. Click it to jump to the exact line |
| Size | Bytes transferred. (memory cache), (disk cache) or (ServiceWorker) mean no network request happened |
| Time | Total duration |
| Waterfall | When the request started and how its time was spent, relative to the others |
Right-click the column header to add more. Two worth adding: Protocol (h2, h3, http/1.1) and Remote Address (the actual IP the browser connected to — useful when DNS might point somewhere unexpected).
Inside one request
Click a request to see its tabs:
- Headers — the request URL, method, status, and every request and response header. This is where you check whether the
Authorizationheader or cookie was actually sent, whatcontent-typecame back, and which hop answered (server,via,x-vercel-id,cf-ray— Chapter 4). If you see the warning "Provisional headers are shown", the request never completed normally — it was served from cache, blocked, or cancelled — so the headers shown are only what the browser intended to send. - Payload — the query string and the request body as sent.
- Preview / Response — the response body, formatted or raw. If you expected JSON and see HTML here, you have found the cause of an
Unexpected token '<'error. - Initiator — the call stack that triggered the request. This answers "which component fired this request?" and "why is this request being sent twice?"
- Timing — where the time went (next section).
- Cookies — the cookies sent and received. Chrome can also show cookies that were blocked, with the reason (for example a
SameSiteorSecurerule). This is the fastest way to answer "why isn't my cookie being sent?"
The timing breakdown — which phase is slow?
The Timing tab splits a request into phases. Each phase points to a different cause.
| Phase | What happens | If this phase is long |
|---|---|---|
| Queueing / Stalled | The browser is waiting to start the request | Too many requests to the same host (HTTP/1.1 allows about 6 connections per host), or lower priority than other requests |
| DNS Lookup | Resolving the hostname | Slow DNS, or a host the browser has not resolved before |
| Initial connection / SSL | TCP connect and TLS handshake | A new connection to a distant server; connections are not being reused |
| Request sent | Uploading the request | A large upload or a slow uplink |
| Waiting for server response | From sending the request until the first byte arrives — often called TTFB (time to first byte) | The server is slow. This is a backend problem: slow query, lock, slow upstream call |
| Content Download | Receiving the body | A large response, or a slow connection |
This one tab often settles the frontend-vs-backend argument in seconds. If 1.9 of 2 seconds is Waiting for server response, no amount of frontend optimisation will help.
Read the waterfall for chains
Look at the shape of the waterfall, not only individual bars. Requests stacked in a staircase — each starting when the previous one finishes — are a request waterfall: the page fetches the user, then the user's team, then the team's projects, one after another. Each step waits for the previous one. That shape is a design problem: those requests could often run in parallel or be combined.
Reproduce outside the page
Right-click a request to get powerful options:
- Copy → Copy as cURL (or Copy as fetch) — the exact request, with headers and body, as a command you can run in a terminal or paste into a script. This removes the browser from the question: if the cURL fails the same way, it is not a frontend bug. (Chapter 6 uses this heavily.)
- Block request URL / Block request domain — make a resource fail on purpose to see how the page copes. Great for testing error states.
- Override content / Override headers — serve your own version of a response or header locally, without deploying anything. Test "would this fix it?" in seconds.
- Save all as HAR — export every request in the session as a HAR file (HTTP Archive) for a teammate or a support ticket.
A HAR file contains everything — cookies, auth tokens, request bodies. Treat it like a password. Use the sanitised export if your browser offers it, or strip sensitive headers before sharing.
WebSockets and streams
For a WebSocket request (type websocket, status 101), open the Messages tab to see every frame sent and received, in order. For server-sent events (text/event-stream), the EventStream tab shows each event. If a real-time feature "stops updating", check whether frames are still arriving — that separates "the server stopped sending" from "the client stopped rendering."
Part 3: The Console — More Than a Log Viewer
Read every level, not just red
The Console filters messages by level: Verbose, Info, Warnings, Errors. console.debug output is Verbose, which is hidden by default. If you are sure a log line should appear and it does not, check the level filter first.
Warnings matter. Some of the most useful browser messages are warnings, not errors: an invalid form control that cannot be focused, a cookie rejected for its SameSite setting, a deprecated API. The worked example at the end of this chapter is solved by a warning.
The browser prints things here that your JavaScript can never see: the real reason for a CORS failure, a Content Security Policy violation, a mixed-content block. If fetch rejected with a generic Failed to fetch, the explanation is in the Console.
Check which context you are in
The dropdown at the top of the Console (usually labelled top) selects which JavaScript context your typed code runs in: the main page, an iframe, a web worker, or an extension. If a variable "doesn't exist" in the Console but clearly exists in the app, you may be in the wrong context.
The utilities worth knowing
| Utility | What it does |
|---|---|
$0 | The element currently selected in the Elements panel |
$_ | The result of the last expression you evaluated |
copy(value) | Copy any value to the clipboard as text — great for grabbing a large API response |
console.table(rows) | Show an array of objects as a table |
console.trace() | Print the current call stack — "who called this?" |
console.count(label) | Count how many times a line runs — "why does this render 14 times?" |
console.time(label) / console.timeEnd(label) | Measure how long something takes |
getEventListeners($0) | List the event listeners attached to the selected element |
monitorEvents($0, 'click') | Log every click event on that element |
queryObjects(Constructor) | List every live object created by a constructor — useful for leak hunting |
Live expressions (the eye icon at the top of the Console) pin an expression that re-evaluates continuously — for example document.activeElement to watch focus move.
The live-object trap
console.log(obj) does not take a snapshot. The Console stores a reference to the object, and when you expand it later, it shows the object's current values, not the values at the moment of logging.
const cart = { items: 1 }{ items: 1 }To log a true snapshot, log a copy: console.log(structuredClone(cart)) or console.log(JSON.stringify(cart)). Better still, use a breakpoint (Part 4), which shows values exactly as they are at that moment.
Part 4: The Sources Panel — Pausing Time
console.log shows you one value at one line, and you must edit and reload to add another. A breakpoint pauses the program, and while it is paused you can inspect every variable, walk the call stack, and step forward one line at a time. Breakpoints are not an advanced technique. They are the default tool, and console.log is the fallback.
Open a file with Cmd/Ctrl+P. Search across every loaded file with Cmd+Option+F (Mac) or Ctrl+Shift+F. If the code is minified, click {} (pretty print). If source maps are working, your original files appear in the file tree.
The kinds of breakpoint — and when each one wins
| Breakpoint | How to set it | Use it when |
|---|---|---|
| Line breakpoint | Click a line number | You know where to look |
| Conditional breakpoint | Right-click a line number → Add conditional breakpoint | The line runs a thousand times and you care about one case: order.id === 'A-1042' |
| Logpoint | Right-click a line number → Add logpoint | You want a console.log without editing the code — works even on a deployed site |
debugger; statement | Write it in your code | You want a breakpoint that travels with the code (remove it before committing) |
| XHR/fetch breakpoint | Sources → XHR/fetch Breakpoints → URL contains /api/orders | "Who is sending this request?" — pauses at the exact call |
| Event listener breakpoint | Sources → Event Listener Breakpoints → e.g. Mouse → click | "What runs when I click this?" — pauses in the handler, even if you have no idea where it lives |
| DOM breakpoint | Elements → right-click a node → Break on → subtree modifications / attribute modifications / node removal | "What code changed or removed this element?" |
| Exception breakpoint | Sources → Breakpoints → Pause on uncaught exceptions / Pause on caught exceptions | "Where is this error thrown?" — and with caught enabled, it finds swallowed errors (Chapter 2) that never reach the Console |
Pause on caught exceptions deserves special mention. When something "silently fails", turn it on and reproduce. The debugger stops at the throw inside the try block that was hiding it.
While paused
| Pane | What it shows |
|---|---|
| Scope | Every variable in scope right now — local, closure, module, global. You can edit values here to test a theory |
| Watch | Expressions you pin, re-evaluated at every pause |
| Call Stack | How execution got here, including async callers. Click any frame to see its variables |
| Console | Typed expressions run in the paused frame's scope — you can call functions with the real local values |
The stepping controls:
- Resume (F8) — run until the next breakpoint.
- Step over (F10) — run the current line, without going into functions it calls.
- Step into (F11) — go into the function call on this line.
- Step out (Shift+F11) — finish the current function and return to its caller.
Skip the framework: the ignore list
Stepping into a React or library function drops you into thousands of lines you do not care about. Right-click a file or folder → Add script to ignore list. Ignored code is skipped when stepping and collapsed in call stacks, so you only ever stop in your own code. Chrome ignores many known third-party scripts by default.
Part 5: The Application Panel — The State You Can't See
Many "impossible" bugs are caused by state the browser is holding: an old cookie, a stale cache, a service worker serving last week's code. The Application panel shows all of it.
Cookies — why isn't my cookie sent?
The Cookies view lists every cookie with its attributes. Each attribute is a rule the browser enforces silently:
| Attribute | The rule | The bug it causes |
|---|---|---|
| Domain / Path | The cookie is only sent to matching hosts and paths | A cookie set for app.example.com is not sent to api.example.com |
| Expires / Max-Age | After this time the cookie is gone | "Users get logged out after exactly 24 hours" |
| HttpOnly | JavaScript cannot read it (document.cookie won't show it) | Not a bug — a security feature. Your code cannot read an HttpOnly auth cookie, by design |
| Secure | Only sent over HTTPS | The cookie works in production but not on http:// in local dev |
| SameSite | Controls sending on cross-site requests: Strict (never), Lax (the default — only top-level navigations), None (always, but requires Secure) | A frontend on one site calling an API on another site gets 401 because the session cookie is not sent |
When a request is missing a cookie, open that request in the Network panel's Cookies tab. Chrome can show cookies it filtered out for that request, with the reason. That turns a long guessing session into a single click.
Storage
Local Storage, Session Storage and IndexedDB are browsable and editable here. Two classic bugs live in storage:
- A value stored by an old version of the app has a shape the new version does not expect.
Unexpected token 'u', "undefined" is not valid JSONoften comes from here. - The bug only happens for returning users, never for you — because you cleared your storage. Reproduce with the old stored data.
Storage → Clear site data wipes everything for the origin in one click. Use it to reproduce the "first visit" experience exactly.
Service workers — the most common "stale app" cause
A service worker is a script the browser keeps running in the background. It can intercept every request and answer from its own cache. That is how offline support works — and it is also how users end up running an old version of your app after a deploy, because the old service worker keeps serving old files.
In Application → Service workers you can see whether one is active and what state it is in, and use three switches:
- Update on reload — install the newest service worker on every reload while debugging.
- Bypass for network — skip the service worker entirely and go to the network.
- Unregister — remove it.
Remember: "Disable cache" in the Network panel does not bypass a service worker. If responses show (ServiceWorker) in the Size column, the network was never touched. (FE-09 Scenario 7 walks through a full "users still see the old version" investigation.)
Part 6: Elements and Performance — Briefly
Elements answers "why does this look wrong?":
- The Styles pane shows every CSS rule that matched the element. A struck-through declaration lost to a more specific rule; hover it to see which one won.
- Computed shows the final value of every property — the truth, after all the rules.
:hovforces states like:hoverand:focusso you can inspect styles that disappear when you move the mouse.- The Event Listeners tab lists the handlers attached to an element and links to their code.
Performance answers "what was the main thread doing?". Record while reproducing the slowness, then look at the Main track: wide blocks are long-running work, and tasks over 50 ms are flagged as long tasks (a red corner). Use Bottom-Up to see which functions took the most time overall. The details belong to FE-04 and FE-06; for debugging, the key move is simply to record instead of guessing what is slow.
Part 7: Reproducing the User's Environment
"It works for me" usually means "my environment is different from theirs." DevTools can close most of that gap.
| To simulate | Use |
|---|---|
| A phone screen | Device mode (Cmd/Ctrl+Shift+M): viewport size, touch, device pixel ratio, user agent. It is not a real device — it does not reproduce the phone's CPU, memory or browser engine |
| A slow phone | Performance panel → CPU throttling (4× or 6× slowdown) |
| A slow or flaky network | Network panel → Throttling, or Offline |
| A different location, time zone or language | Command Menu → Sensors: geolocation, time zone, locale |
| Dark mode, reduced motion | Command Menu → Rendering: emulate prefers-color-scheme, prefers-reduced-motion |
| No JavaScript | Command Menu → Disable JavaScript — shows what the server-rendered HTML alone looks like |
Three habits that eliminate whole categories of causes:
- Try a private (incognito) window. It has no extensions and no stored state. If the bug disappears, the cause is an extension (
ERR_BLOCKED_BY_CLIENT) or stored data. - Try another browser. Safari behaves differently from Chrome in many ways, and every browser on iPhone uses Safari's engine (WebKit), so "broken on iPhone Chrome" is a Safari bug.
- Debug the real device when it matters. Connect an Android phone by USB and open
chrome://inspecton your computer to get full DevTools for the phone's browser. For iPhone, enable Web Inspector on the phone and use Safari's Develop menu on a Mac.
Part 8: The Browser Investigation, Step by Step
When a bug shows up in the browser, this order wastes the least time:
- Console. Every level, including warnings. Read every message in full.
- Network. Find the request that matters. Read its status, who sent the response (headers), and the actual body. Check the Timing tab if it is slow.
- Initiator. Jump from the request to the code that sent it.
- Pause. Set a breakpoint, a conditional breakpoint, or a logpoint where the evidence points. If you do not know where, use an event-listener, XHR/fetch, DOM or exception breakpoint to let the browser find it for you.
- State. If the bug depends on the user, check cookies, storage and service workers in Application.
- Environment. Incognito, throttling, another browser, the real device — to match the user's conditions.
The Worked Example — "Clicking Save Does Nothing"
Symptom: A user fills in the account settings form and clicks Save. Nothing happens. No error appears.
1. Console. With the default filter set to Errors only, the Console is empty. Switching the levels to include Warnings reveals one line: An invalid form control with name='company' is not focusable.
2. Network. No request is sent when you click Save. That rules out the backend entirely: the problem is before fetch.
3. Pause. Even though the warning is a strong lead, confirm it. Sources → Event Listener Breakpoints → Mouse → click. Click Save. The debugger pauses inside the form's submit handling. Step over a few lines and you reach if (!form.checkValidity()) return. In the Scope pane, the form's company input has validity.valid === false.
4. Why? In Elements, the company input is inside a section hidden with display: none (it is only shown for business accounts), but it still has the required attribute. The browser's built-in validation refuses to submit a form with an invalid required field — and because the field is hidden, it cannot show the user the usual "please fill in this field" bubble. So it silently refuses, and logs the warning.
5. Root cause and fix. A required field that is hidden is still validated. Remove required (or disable the input) when its section is hidden, and add a test for the personal-account path.
Notice the shape: the answer was in the Console the whole time, at a level that was filtered out. The breakpoint turned a lead into proof.
The Causal Chains
- If a bug involves a redirect or a page navigation
- If most of a slow request's time is "Waiting for server response"
- If a request spends a long time "Stalled" or "Queueing"
- If the Size column shows
(memory cache),(disk cache)or(ServiceWorker) - If you need to know which code sent a request
- If something "silently fails"
- If an element changes or disappears and you don't know why
- If an expanded object in the Console shows values that seem impossible at that line
- If a request lacks a cookie
- If users see an old version after a deploy
- If the bug disappears in an incognito window
- If
fetchfails with onlyFailed to fetch
The Rule You Keep Forever
The Network panel is the truth about what was sent and received; the Sources panel is the truth about what the code did. Read the evidence there before trusting your mental model — and when you don't know where to look, let an event, fetch, DOM or exception breakpoint find the line for you.
Vocabulary Map
| Term | What it means |
|---|---|
| DevTools | The browser's built-in debugging instruments |
| Command Menu | Cmd/Ctrl+Shift+P — run any DevTools feature by name |
| Preserve log | Keep Network and Console entries across navigations |
| Throttling | Simulating a slower network or CPU |
| Initiator | The code (call stack) that caused a request |
| TTFB | Time to first byte — how long until the server started responding |
| Request waterfall | Requests made one after another because each depends on the previous |
| Provisional headers | Headers the browser intended to send for a request that did not complete normally |
| HAR file | An export of every network request in a session — contains secrets |
| Copy as cURL | Exporting a browser request as a terminal command to reproduce it outside the browser |
| Local overrides | Serving your own version of a response or header locally, without deploying |
| Breakpoint | A place where execution pauses so you can inspect state |
| Conditional breakpoint | A breakpoint that pauses only when an expression is true |
| Logpoint | A breakpoint that logs a message instead of pausing — no code edit needed |
| XHR/fetch breakpoint | Pauses when a request to a matching URL is sent |
| Event listener breakpoint | Pauses when an event of a given type is handled |
| DOM breakpoint | Pauses when a node's subtree or attributes change, or the node is removed |
| Exception breakpoint | Pauses when an exception is thrown — uncaught only, or caught too |
| Call stack | The chain of calls that led to the current line |
| Step over / into / out | Run the line / enter the called function / finish the current function |
| Ignore list | Scripts DevTools skips while stepping, usually library code |
| Execution context | Which page, frame, worker or extension the Console evaluates in |
| HttpOnly / Secure / SameSite | Cookie rules: hidden from JS / HTTPS-only / restricted on cross-site requests |
| Service worker | A background script that can intercept requests and serve them from its own cache |
| Device mode | Emulating a phone's viewport and user agent — not its CPU or engine |
| Remote debugging | Running DevTools on your computer against a browser on a real phone |
Practice Problems
Problem 1 — Which panel? For each, name the first panel and the exact feature you would use: (a) "Which component sends this /api/me request twice on page load?" (b) "Something removes the promo banner a second after it appears." (c) "The checkout request takes 4 seconds — is it us or the API?" (d) "The error happens, but nothing appears in the Console."
Problem 2 — Read the timing. A request takes 2.4 s: Stalled 1.8 s, Waiting for server response 0.2 s, Content Download 0.1 s. A teammate wants to optimise the API. What do you tell them, and what do you check instead?
Problem 3 — The missing cookie. Your frontend at app.example.com calls api.example-cdn.net with credentials: 'include'. The API returns 401, but works when you call it directly in the browser's address bar. Using the Application and Network panels, how would you find the cause, and what is the most likely one?
Problem 4 — The impossible log. You log state before an update, and the expanded Console object already shows the updated values. Explain exactly why, and give two ways to see the true value at that line.
Problem 5 — Stale after deploy. After a release, some users still get the old UI even after a hard refresh. Your Network panel shows JS files with (ServiceWorker) in the Size column. Explain what is happening and what you would check and change.
What's Coming Next
The browser shows you one end of a request. Chapter 6 follows the request off the device: DNS, TCP, TLS, HTTP, proxies and caches, one hop at a time. You will take the "Copy as cURL" command from this chapter and use curl's verbose and timing output to see each hop yourself — the fastest way to answer "is it the browser, the network, or the server?"