In this guide
How to Debug a Full-Stack Web App: DevTools, Network Tab and Backend Logs
The Scenario Every Student Hits
You deployed your project to a VPS. On localhost everything worked: login, dashboard, the works. Now the live site shows a blank white page, or the login button spins forever, or the API returns an error you have never seen locally.
The natural reaction is to start changing code and redeploying to "see if it works now." That loop — guess, change, deploy, wait, check — eats hours and usually teaches you nothing. A full-stack app has three moving layers (frontend, network, backend), and each layer has its own observability tools. The fix is not more guessing; it is a repeatable workflow that tells you which layer owns the bug in minutes, then uses the right tool to read what actually happened.
This guide gives you that workflow. It assumes a typical student stack — a React/Vue/Next frontend talking to a Node/Express or Django REST backend over HTTP — but the steps apply to any full-stack web app.
Step 1 — Reproduce and Narrow: Which Layer Owns the Bug?
Before opening any tool, answer two questions. This takes about two minutes and eliminates most blind alleys.
Question 1: Can you reproduce it on demand?
Write down the exact reproduction steps: the URL, the button you clicked, the inputs you typed, and what you expected vs. what you saw. If you cannot reproduce it, you cannot verify a fix either. Reload the page once; if the bug vanishes, note that — intermittent bugs are a different animal (covered in Limitations).
Question 2: Which layer is the source of truth failing in?
Open the browser DevTools (F12) and the Network tab, reload, and reproduce. Now look at the failing request:
- No request leaves the browser (nothing in the Network tab, or a CORS error in the Console): the problem is on the frontend or the network layer — a wrong API base URL, a CORS misconfiguration, or the fetch call itself.
- Request goes out, response comes back with a 4xx status: the backend received it and refused it — a bad route, failed validation, missing auth token, or an expired JWT. Read the response body; backends usually explain themselves here.
- Request goes out, response comes back 5xx: the backend crashed or threw an uncaught exception. The browser can tell you nothing more; the answer is in the server logs (Step 3).
- Request goes out and gets no response at all (pending forever, then ERR_CONNECTION_REFUSED or a timeout): the backend or the server is down — check whether the process is running, whether the port is open, and whether a reverse proxy is forwarding to the right port.
- Everything returns 200 but the page is wrong: a frontend logic or caching bug — stale data, a state bug, or the browser serving an old JS bundle.
This two-minute triage decides where you spend the next twenty. Most students skip it and spend two hours in the wrong layer.
Step 2 — Browser DevTools: Your Frontend Crime Scene
DevTools is not just a console for console.log. Three tabs do the heavy lifting.
The Console tab: read real errors, not the first red line
The Console shows JavaScript errors, but students misread it constantly:
- Read the stack trace, not just the message.
TypeError: Cannot read properties of undefined (reading 'name')means some variable was undefined — click the file/line link on the right to jump to the exact line in the Sources tab. - Source maps matter: if your build tool emits source maps, errors point at your real files; without them, you get minified gibberish. If you see
app.8f3c2a.js:1:24811, check whether source maps are enabled in your build config. - Ignore benign noise: favicon 404s, browser-extension warnings, and third-party cookie warnings are usually not your bug. Filter the Console to "Errors" only to see what matters.
- A
console.logis fine for quick checks, but for state bugs use a breakpoint: open the Sources tab, click a line number, reproduce — the debugger pauses and lets you inspect every variable in scope. This is dramatically faster than logging values and refreshing.
The Network tab: the single most underused debugging tool
If your app talks to an API, this tab is where most full-stack bugs are visible. Use it properly:
- Enable "Preserve log" (checkbox in the Network toolbar). Without it, the log clears on every page navigation — and you will lose the failing request when the login form submits and redirects. This one checkbox saves more debugging time than anything else in this guide.
- Filter by type: click "Fetch/XHR" to hide images, fonts and CSS, leaving only API calls.
- Read the status code and the payloads: click a failing request, then look at:
- Headers: the request URL (is it hitting
localhost:5000on the deployed site? wrong base URL — see the checklist), the request method, and theAuthorizationheader (missing or expired token?). - Payload / Preview: what the frontend actually sent. Compare it against what the backend expects. Half of all 400s are a field name mismatch or a missing field.
- Response: what the backend actually returned. A 500 with an HTML error page, a JSON
{"message": "..."}, or an empty body — each tells a different story. - Timing: if a request takes 30 seconds and fails, it is a timeout, not a logic bug — look at the backend's DB queries or external calls.
- Headers: the request URL (is it hitting
- Watch for failed preflights: an
OPTIONSrequest that returns 403/404 before your real request even fires means CORS is blocking you. The real request never happened — no amount of backend logging will show it, because the browser blocked it first. Fix it on the backend's CORS configuration. - "Copy as cURL": right-click any request to copy it as a curl command. Paste it into a terminal and run it — if it fails there too, you have proven the backend is the problem and removed the frontend from the equation entirely.
The Application tab: tokens, cookies and storage
When authentication breaks:
- Check Local Storage / Session Storage: is the JWT actually stored? Is it expired (paste it into jwt.io — offline check, no data sent)?
- Check Cookies: if your backend sets
HttpOnlycookies, confirm the cookie exists, has the right domain/path, and was not blocked because ofSameSiteorSecureattribute mismatches (common after deploying to HTTPS). - Clear site data and try once more: a corrupted or stale stored token is a classic "works in incognito, broken in my browser" bug.
Step 3 — Backend Logs: Where 5xx Bugs Actually Live
The browser cannot tell you why the server threw an exception. The server's logs can — if they are usable. Most student backends log almost nothing useful, so this step has two parts: making logs readable, and reading them.
Make your logs answer three questions
Every request your backend handles should produce log lines covering three things:
- The incoming request: method, path, and the authenticated user (or "anonymous").
- The DB/query outcome: did the query succeed, how many rows, or what error came back.
- The external call result: if you call a third-party API, log the status code you got back.
In Express, a middleware like morgan with a combined format gets you the first; in Django, a logging config writing request lines to a file does the same. The point is not the library — it is that a log line should let you trace one request from arrival to outcome.
Use request IDs
When several users hit the server at once, log lines interleave and you cannot tell which lines belong to your failing request. The standard fix: generate a short unique ID per request (a middleware that sets req.id), include it in every log line for that request, and return it in a response header like X-Request-Id. Then you grep the log for your ID and get a clean, single-request story. Implementing this takes a few lines and pays off on every future bug.
Tailing logs on a VPS
Your server keeps logs wherever the process manager writes them. Three common cases, each a single modest command:
- systemd service:
journalctl -u myapp -fstreams the log live; add--since "10 minutes ago"to bound the output. - pm2:
pm2 logs myapp --lines 100shows the last 100 lines. - Docker:
docker logs -f --tail 100 myapp-containerdoes the same.
Reproduce the bug while the log is streaming. The lines that appear at that exact moment are your evidence. Look for the first error or traceback, not the last log line — frameworks often log a cascade of follow-on errors after the real one.
For Django tracebacks: the first File "...", line N, in <function> block is the root cause; the DoesNotExist, OperationalError, or KeyError at the bottom names the exception type. For Express: uncaught exceptions print the stack to stderr — if your 500 returns an empty body, the stack is sitting in the log right now.
Log levels are a budget
debug is for development. In production, info plus warn/error is the sane default — verbose debug logging in production buries the error you need under thousands of lines, and on small VPSes it fills disks. Set the level with an env var (e.g. LOG_LEVEL) so you can raise it temporarily when chasing a bug without redeploying code.
Step 4 — The Usual Suspects Checklist
In practice, a large share of student full-stack bugs is one of these. Run down the list before you start rewriting code:
| Symptom | Likely cause | Where to confirm |
|---|---|---|
| CORS error in Console; OPTIONS request fails | Backend CORS config does not allow the frontend origin | Backend CORS middleware settings; Network tab preflight |
API calls go to localhost:5000 on the live site |
API base URL hardcoded for development | Frontend env config; Network tab request URLs |
| 404 on every API route in production only | Reverse proxy (Nginx) not forwarding /api to the backend port |
Nginx config; curl against the backend port directly |
| 500 with an empty response body | Uncaught exception on the server | Backend logs at the moment of reproduction |
| New deploy shows old UI / old bugs | Browser cached the JS bundle; bundle filename not fingerprinted | Hard refresh (Ctrl+Shift+R); check bundle filename has a hash |
| "Works in incognito, broken normally" | Stale token or corrupted local storage/cookies | Application tab; clear site data and retry |
| 401 on login only on the deployed site | Missing env var in production (e.g. JWT secret unset) or proxy dropping the Authorization header | Compare env on server vs .env locally; check proxy headers |
| Timeouts on one slow page | DB query without an index, or an external API call with no timeout | Backend logs with query timing; Network tab Timing sub-tab |
| "Connection refused" / backend unreachable | Backend process crashed or is listening on the wrong interface (127.0.0.1 vs 0.0.0.0) | ps / process manager status; try the port from the server itself |
| Everything worked, then broke after a DB change | Connection pool exhausted or migrations not run in production | Backend logs for pool/migration errors; check migration status |
Worked Example: "Login Returns 401 Only on the Deployed Site"
This is a real-shaped bug students hit constantly. Here is the full trace, about twenty minutes if you follow the workflow instead of guessing.
Minute 0–2 (narrow). Localhost login works; deployed login fails with 401. Reproduction is consistent. In the Network tab with Preserve log on, the POST /api/auth/login goes out, and the server answers 401 with body {"error": "invalid signature"}. The frontend sent the credentials (Payload tab confirms username/password are present), so the frontend did its job. The backend rejected a correct password. This is a backend-layer problem.
Minute 2–5 (backend logs). Tail the server logs and log in again. The request line shows the login attempt arriving with a request ID. Right after it: a warning line from the auth middleware — token verification failed. So the login handler did run, found the user, and then failed to sign the JWT. That points at the signing secret, not the password check.
Minute 5–10 (env vars). The code reads the secret with process.env.JWT_SECRET. Locally it comes from .env. On the VPS, the process was started by pm2 with an ecosystem file that sets env vars — but JWT_SECRET was never added there. The code fell back to a default or empty value at boot, signed tokens with it at login… and then the verification middleware, which reads the same env, produced a mismatched signature because a different default was used in another file. Classic: two places reading the same env var with two different fallbacks.
Minute 10–15 (fix). Add JWT_SECRET to the production environment (pm2 ecosystem file or the systemd Environment= line — never commit it to git), restart the backend process, and log in again. 200, token stored, dashboard loads.
Minute 15–20 (harden). Two one-line guards so this class of bug dies permanently: make the backend crash at startup if JWT_SECRET is missing (fail fast beats a silent empty fallback), and return the X-Request-Id header so the next bug is traceable. Note in the project README which env vars production requires.
Total: twenty minutes, zero redeploys to "see if it works," and a root cause you can explain.
Frequently Asked Questions
My console is full of red errors. Which one is the bug?
Usually the first error in time order. Later errors are often consequences — a failed import, then every module that depends on it failing too. Scroll to the top of the Console (or reload with Preserve log on) and fix the earliest one first.
The Network tab shows my request as "(failed)" with no status code. What does that mean?
No status code means the browser never got an HTTP response. Common causes: the server is down, the URL is wrong (check for typos in the domain), the request was blocked by CORS, or an ad blocker/extension interfered. Try the same request with curl to separate browser issues from server issues.
Should I log errors to a file or just the console on a VPS?
Both, via a process manager: pm2 and systemd already capture stdout/stderr to files you can tail. The mistake is logging only to a file your code opens itself — then a crash before the logger initialises loses the traceback. Let the process manager own the log files; your code just writes to stdout/stderr.
How do I debug when the bug only happens on the client's machine?
Ask for three things: the exact steps, a screenshot of the Console errors, and an exported HAR file (Network tab → right-click → "Save all as HAR"). A HAR file lets you replay their exact requests and timings locally. Remote screen-sharing where you watch them reproduce it is the fastest fallback.
Is it safe to leave detailed error messages on in production?
No. Verbose tracebacks in production responses leak file paths, library versions, and sometimes query fragments — useful to attackers and confusing to users. Log the full traceback server-side, return a generic message plus the request ID to the client, and let the client include that ID when reporting the bug.
My backend logs show nothing at all when the bug happens. Now what?
That itself is information: the request likely never reached your backend code. Check the layer in front of it — Nginx error logs, the process manager status (is the app even running?), and the firewall. Also verify you are tailing the right log file; it is embarrassingly common to watch staging logs while reproducing on production.
Limitations
Debugging skill has real boundaries, and knowing them saves you from the "I debugged for six hours" trap:
- Debugging cannot fix bad design. If logins fail because the auth flow was never fully thought through, no amount of log-reading replaces redesigning the flow. When the same class of bug recurs, the fix is architecture, not another breakpoint.
- Intermittent bugs need data, not sessions. A bug that happens once a day will not reproduce while you stare at a tailing log. For those, you need persistent logging plus correlation (request IDs, timestamps) so you can investigate after the fact from stored logs.
- Local/prod parity is never perfect. Your laptop and the VPS differ in env vars, Node/Python versions, file paths, and network topology. Docker narrows the gap but does not close it — the checklist above exists precisely because "but it worked locally" is a symptom, not a defence.
- Add monitoring before you need it. Debugging is reactive. Once an app has real users, a lightweight error tracker (even a free-tier service or a simple error-webhook to your own endpoint) beats tailing logs after a user reports a crash. Debugging finds the bug; monitoring tells you it happened.
- Logs are not the whole system. A 500 caused by a full disk, an expired SSL certificate, or a database that ran out of connections will not always produce a tidy application traceback — sometimes you must check system-level state (disk, certs, DB health) too.
Learn the workflow, keep the checklist handy, and the next "works on localhost" incident becomes a twenty-minute investigation instead of a lost evening.