Debugging Web Applications
Before you start
You need: a frontend calling an API, and code debugging (Article 02).
Time: about 50 minutes, at the keyboard.
Learning objective
Diagnose a browser-side failure and determine, from evidence, whether the fault is in the frontend or the API.
Topics
- The first question
- The Network panel
- Status codes as a routing rule
- The Console
- Elements and why CSS "doesn't work"
- Application: storage, tokens and cookies
- CORS
- Sources and breakpoints in JavaScript
The first question
Something on the page is wrong. Before reading any code, open DevTools (F12) and answer one question:
Did the request happen, and what came back?
Network tab → find the request → status code → Response tab
| Observation | Conclusion |
|---|---|
| No request at all | Frontend — the event handler never fired, or a condition prevented the call |
| Request sent, 200, correct data | Frontend — the rendering is wrong |
| Request sent, 200, wrong data | Backend — the query or mapping is wrong |
| Request sent, 4xx | Usually frontend — bad input, missing token |
| Request sent, 5xx | Backend — an unhandled exception |
| Request sent, CORS error | Backend configuration |
| Request pending forever | Backend hung, or the wrong URL |
This single check ends most frontend-versus-backend arguments in thirty seconds, and it should come before opening an editor.
The Network panel
Turn on Preserve log before reproducing, or a redirect wipes the evidence.
For each request:
| Sub-tab | Shows |
|---|---|
| Headers | URL, method, status, request and response headers |
| Payload | What was sent |
| Preview / Response | What came back |
| Timing | Where the time went |
Check in this order:
- The URL. Is it what you expect? A double slash, a missing
/api,undefinedin the path — all common, all visible here. - The method. A
GETwhere the API expectsPOSTreturns 405. - Request headers. Is
Authorization: Bearer …present? IsContent-Type: application/jsonset? - The payload. Are the field names exactly what the API expects? Casing matters.
- The response. Read the body — an API usually explains the 400.
Filter: Fetch/XHR hides scripts, images and stylesheets
Copy as cURL (right-click → Copy → Copy as cURL) reproduces the exact request outside the browser. Run it in a terminal or import it into Postman: if it fails there too, the frontend is exonerated.
Timing separates a slow API from a slow page. Waiting (TTFB) is the server thinking; Content Download is the payload size. 2 seconds of TTFB is a backend problem; 2 seconds of download is a payload problem.
Status codes
| Code | Meaning | Owner |
|---|---|---|
| 200 | OK | — |
| 201 | Created | — |
| 204 | No content | — |
| 304 | Not modified, cached | Usually fine |
| 400 | Bad request | Frontend — read the response body |
| 401 | Unauthenticated | Token missing, malformed or expired |
| 403 | Authenticated, not allowed | Backend authorisation — correct behaviour, usually |
| 404 | Not found | Wrong URL, or the record does not exist |
| 405 | Method not allowed | Wrong verb |
| 415 | Unsupported media type | Content-Type missing or wrong |
| 500 | Server error | Backend — an unhandled exception |
| 502 / 504 | Gateway error / timeout | Infrastructure, or the API is down |
401 versus 403 is the distinction people get wrong. 401 means "I do not know who you are"; 403 means "I know, and you may not". A 403 on a page a user should reach is an authorisation bug; a 403 on one they should not is the system working.
415 almost always means Content-Type: application/json was not sent. A body posted as plain text produces it, and the message never says so directly.
Never treat a 500 as a frontend problem. It is an unhandled exception on the server; the fix is in the API logs.
The Console
console.log(student);
console.table(students); // an array of objects as a grid
console.error(err);
console.warn("Falling back to cached list");
console.dir(element); // the DOM object, not the rendered element
console.count("render"); // how many times this ran
console.time("load"); console.timeEnd("load");
console.table on an array of objects is far more readable than console.log, and it makes a wrong field name obvious at a glance.
Shortcuts in the console itself:
$0 // the element currently selected in Elements
$('.student-row') // document.querySelector
$$('.student-row') // document.querySelectorAll, returns an array
copy(students) // copy to clipboard
Read the stack trace in a console error. Click the file:line link to jump straight to the source. Frames from framework bundles above your own code are noise.
Errors you will meet:
| Error | Cause |
|---|---|
Cannot read properties of undefined (reading 'name') | The object is not there yet — data still loading, or the field is named differently |
X is not a function | Wrong import, or the value is not what you think |
Unexpected token < in JSON at position 0 | The response was HTML, not JSON — usually a 404 or an error page |
Failed to fetch | Network, wrong URL, or CORS |
Unexpected token < is worth memorising. It means the code called .json() on an HTML error page — so the real problem is the status code, visible in Network.
Elements
Elements shows the live DOM, not the source. View Source shows what the server sent; Elements shows what the DOM is now, after JavaScript has run. When they differ, the difference is what your JavaScript did.
When "the CSS isn't working", there are three causes and the Styles pane distinguishes them:
| Evidence in Styles | Cause |
|---|---|
| The rule is listed but struck through | Overridden — a more specific selector wins |
| The rule is not listed at all | Selector does not match, or the file did not load |
| The rule applies but nothing moves | The property is not doing what you think — check Computed and the box model |
Check Computed for the final value, and the box model diagram for what is actually margin, border and padding.
:hov forces :hover, :focus and :active so you can style a state without chasing it with the mouse.
Break on a DOM change: right-click an element → Break on → attribute modifications / subtree modifications. This is how you find the code that adds a class or empties a container, when you have no idea where it lives.
Application
| Section | Check |
|---|---|
| Local Storage | Is the token there? Is it the current one? |
| Session Storage | Cleared on tab close — deliberate? |
| Cookies | HttpOnly, Secure, SameSite, expiry |
| Service Workers | Stale cache — unregister while debugging |
| Clear storage | Reproduce the first-time-user experience |
Most "randomly logged out" reports are token expiry. Copy the token, decode it at jwt.io, and read exp. If it is in the past, the bug is that the app does not refresh or redirect — not that the login broke.
A service worker serving a stale bundle explains "my change didn't deploy" when the change is definitely deployed. Unregister it, hard reload, and check again before investigating the pipeline.
Ctrl+Shift+R hard reloads. With DevTools open, Network → Disable cache is more reliable.
CORS
Access to fetch at 'https://localhost:7099/api/students' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
CORS is enforced by the browser and configured on the server. Nothing you write in the frontend fixes it.
builder.Services.AddCors(options =>
{
options.AddPolicy("SchoolPortal", policy =>
{
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
app.UseCors("SchoolPortal"); // after UseRouting, before UseAuthentication
Three things that catch people out:
AllowAnyOrigin and AllowCredentials cannot be combined. The browser rejects it, and the error does not explain why.
UseCors in the wrong position does nothing. It must come after UseRouting and before UseAuthentication.
Postman does not enforce CORS. A request working in Postman and failing in the browser is the signature of a CORS problem — not evidence that the API is fine.
A OPTIONS request in the Network tab is the preflight. If it fails, the real request never goes out.
Sources and JavaScript breakpoints
async function loadStudents(schoolId) {
const response = await fetch(`/api/students?schoolId=${schoolId}`); // breakpoint
const students = await response.json();
renderStudents(students);
}
- Click the line number to set a breakpoint
- Right-click → Add conditional breakpoint:
schoolId === 3 - Right-click → Add logpoint: logs without pausing and without editing the file
debugger;in code stops when DevTools is open
F10 step over, F11 step into, Shift+F11 step out — the same keys as Visual Studio.
Enable "Pause on caught exceptions" in the Sources panel when a value is wrong and nothing is logged. It is the browser equivalent of first-chance exceptions.
Check for source maps if you are stepping through minified code. Without them, you are debugging one long line.
Diagnosing
| Symptom | First check |
|---|---|
| Button does nothing | Network — was a request sent at all? |
| Blank page | Console — a JavaScript error stopped rendering |
Unexpected token < | Network — the response was HTML |
| Data appears then vanishes | A second request overwriting, or a re-render |
| 401 on every call | Application → token present and unexpired? |
| Works in Postman, not the browser | CORS |
| CSS ignored | Elements → Styles — struck through, or absent? |
| Change not appearing | Service worker or cache; hard reload |
| Slow page | Network → Timing — TTFB or download? |
Errors you will hit
| Observation | Conclusion |
|---|---|
| No request in the Network tab | Frontend — the handler never fired |
| 200 with correct data, wrong display | Frontend rendering |
| 200 with wrong data | Backend |
| 4xx | Usually the caller — read the response body |
| 5xx | Backend — check the API log |
| CORS error | Server configuration |
One check settles most frontend-versus-backend arguments: open Network, find the request, read the status and the body.
Common mistakes
- Reading code before opening the Network tab
- Not enabling Preserve log, losing the request to a redirect
- Treating a 500 as a frontend bug
- Trying to fix CORS in JavaScript
- Concluding the API is fine because Postman works
- Not reading the response body of a 400
- Assuming Elements shows the source
- Ignoring a stale service worker
- Debugging minified code without source maps
- Missing
Content-Type: application/jsonand puzzling over 415
Practice
The course exercise is debug a browser failure.
- Break a button handler so no request fires. Confirm from Network that nothing was sent.
- Call a wrong URL and identify the 404 from Network rather than from code.
- Send a
POSTwithoutContent-Type. Get the 415 and fix it. - Send a request with a field name in the wrong case. Read the 400 response body.
- Remove the
Authorizationheader and get a 401. Then use a token for a role without permission and get a 403. Note which is which. - Copy a failing request as cURL and reproduce it outside the browser.
- Return HTML from an endpoint and trigger
Unexpected token <. Trace it back to the status code. - Use
console.tableon a list of students and spot a wrong field name. - Select an element, then manipulate it with
$0in the console. - Break CSS three ways — specificity, a wrong selector, and a file that fails to load — and identify each from the Styles pane.
- Set a DOM breakpoint on subtree modification and find the code that clears a list.
- Decode an expired token from local storage at
jwt.ioand confirm the 401 matchesexp. - Call your API from a different origin without a CORS policy, then fix it server-side.
- Combine
AllowAnyOriginwithAllowCredentialsand see it fail. - Move
UseCorsafterUseAuthenticationand confirm it stops working. - Set a conditional breakpoint and a logpoint in Sources on a function called many times.
Exercises 13 to 15 are the CORS set. Do all three — the failure modes look identical from the frontend and have different fixes.
You can now
- Decide from the Network tab which side owns a failure
- Read status codes correctly, including 401 versus 403
- Diagnose CSS from the Styles pane
- Inspect storage and decode an expired token
- Recognise a CORS error and say where it is fixed
Review questions
- What does "no request in the Network tab" tell you?
- What is the difference between 401 and 403?
- Why does a request working in Postman not prove the API is correct for the browser?
- What three causes make CSS appear not to work, and how does Styles distinguish them?
Next: Debugging APIs