A login form has two inputs and a button. The code to submit it is about ten lines.
Behind that button there are at least a dozen separate systems, and any of them can be the reason your form "doesn't work." Most of us only debug the first and last ones.
This post follows one login request all the way down and back up. At each layer I'll cover what happens, what can break, and how to look at it yourself.
Here's the form:
<form id="login">
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Log in</button>
</form>
The whole trip on one page
sequenceDiagram
participant B as Browser
participant D as DNS
participant E as Edge (CDN / load balancer)
participant A as App server
participant DB as Database
B->>B: click, validation, submit event, fetch()
B->>D: who is api.example.com?
D-->>B: IP address
B->>E: TCP or QUIC handshake, then TLS
B->>E: POST /api/login
E->>A: forward request
A->>DB: SELECT user by email
DB-->>A: row with password hash
A->>A: verify hash, create session
A-->>E: 200 + Set-Cookie
E-->>B: response
B->>B: update state, render
One caveat before we start. Steps 3 and 4 often don't happen. If the browser already has an open connection to that host, it reuses it and skips straight to sending the request. I'll show the full cold-start path because it's the most informative, but a warm request is much shorter.
1. The click: from your finger to a fetch() call
Clicking the button doesn't send anything. The click fires a click event, which bubbles up the DOM. Its default action is to submit the enclosing form.
Before the submit event fires, the browser runs built-in constraint validation (required, type="email", pattern). If a field is invalid, the browser shows its error bubble and no submit event fires at all. That's why a handler you put on submit sometimes seems to be ignored.
If validation passes, you get the submit event:
const form = document.querySelector("#login");
form.addEventListener("submit", async (event) => {
event.preventDefault(); // stop the native full-page navigation
const data = Object.fromEntries(new FormData(form));
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
});
Without preventDefault(), the browser does a native form submission: it builds the request itself and navigates to the response, reloading the page. That still works, and it's often the more robust choice, but most SPAs intercept it.
What can go wrong here:
- A handler throws before
fetch()runs. -
preventDefault()is missing, so the page reloads and your state is lost. - Validation blocks the submit and you never see an event.
2. Before the request leaves: CORS, cookies, CSRF
When you call fetch(), the browser decides what it's allowed to send.
CORS only matters if the request is cross-origin (different scheme, host, or port). A POST with Content-Type: application/json isn't a "simple" request, so the browser first sends an OPTIONS preflight asking the server whether this origin, method, and header combination is allowed. If the server doesn't answer correctly, the real request is never sent.
You'll see the classic error in the console:
Access to fetch at 'https://api.example.com/login' from origin
'https://app.example.com' has been blocked by CORS policy
Two points that trip people up:
- CORS is enforced by the browser.
curlwill succeed against the same endpoint, which is a quick way to tell a CORS problem from a server problem. - The server can receive the request and process it; the browser just refuses to hand you the response.
Cookies are attached automatically for same-origin requests, subject to SameSite, Secure, and domain rules. For cross-origin requests you need credentials: "include" and a matching server configuration.
CSRF is the reason session-cookie apps also need CSRF tokens or SameSite cookies: a malicious page can make the browser send your cookies to a site you're logged into. Authorization headers set by your own JavaScript aren't attached automatically, which is part of why token-in-header APIs are less exposed to CSRF (and more exposed to XSS if you store the token carelessly). Both models have trade-offs.
3. DNS: finding the server
The browser has a URL, but it needs an IP address. It checks, in rough order:
- Its own DNS cache
- The OS resolver cache
- A recursive resolver (your ISP's, or something like 1.1.1.1 or 8.8.8.8)
- If the resolver doesn't have it cached: the root servers, then the TLD servers (
.com), then the domain's authoritative nameserver
The result is cached according to its TTL, so most lookups never go past step 2 or 3.
dig api.example.com +short
Failure mode: a wrong or missing record, an expired domain, or a stale cache after a migration, which looks like "works for me, fails for them."
4. Connection: TCP or QUIC, then TLS
Now the browser opens a connection.
HTTP/1.1 and HTTP/2 run over TCP:
Client Server
SYN ------------------>
<------------------ SYN-ACK
ACK ------------------>
Then TLS negotiates encryption: the server presents a certificate, the browser validates it against trusted authorities, and both sides derive session keys. With TLS 1.3, that's one extra round trip after TCP.
HTTP/3 runs over QUIC, which uses UDP. QUIC folds the transport and TLS 1.3 handshakes together, so a new connection needs fewer round trips.
Two honest caveats:
- Browsers keep connections open and reuse them. A returning user often skips this whole stage.
- TLS protects data in transit. It does nothing for how the password is stored in the database (step 8).
Check the certificate and negotiated protocol yourself:
openssl s_client -connect api.example.com:443 -servername api.example.com
curl -v https://api.example.com/
Failure modes: expired or mismatched certificate (browser blocks it), connection timeout, a firewall dropping packets.
5. The HTTP request, then the packets
Over the secure connection, the browser sends:
POST /api/login HTTP/1.1
Host: api.example.com
Content-Type: application/json
Cookie: ...
{"email":"user@example.com","password":"..."}
(In HTTP/2 and HTTP/3 this is binary-framed and the headers are compressed, but the semantics are the same.)
That data gets encrypted by TLS, split into packets, and handed to the OS network stack. On a typical Ethernet or Wi-Fi link the MTU is 1500 bytes, and a small login request usually fits in one or a few packets.
On your local network, the OS needs the MAC address of your default gateway. For IPv4 that's found with ARP (IPv6 uses Neighbor Discovery). Your router then performs NAT, rewriting your private address to its public one, and forwards the packet to your ISP.
From there, routers across the internet forward it hop by hop toward the destination network. You can see the hops:
traceroute api.example.com # tracert on Windows
Failure modes: packet loss (TCP retransmits, so you see latency rather than errors), congestion, and a misbehaving middlebox.
6. The edge: CDN, load balancer, reverse proxy
In production, your request rarely hits the app directly. It usually reaches something like a CDN, a load balancer, or a reverse proxy such as nginx first.
These layers often terminate TLS (decrypt the traffic), apply rate limiting or a WAF, choose a healthy backend, and forward the request, frequently over a separate internal connection.
Client -> CDN/LB -> reverse proxy -> app server
This is where 502, 503 and 504 errors usually come from. They mean "I'm the proxy and the server behind me failed, was unavailable, or timed out", not "your code threw."
7. The server's OS and kernel
Here's the part most frontend developers never see. Your backend (Node, Java, Go, whatever) runs in user space. It can't touch network hardware directly. The kernel does that.
The path for an incoming request on Linux, simplified:
NIC -> DMA into a ring buffer -> interrupt / polling
-> kernel network stack (IP, TCP)
-> socket receive buffer -> your process
- The network card writes incoming packets into memory using DMA, then signals the kernel.
- The kernel's TCP/IP stack validates and reassembles the byte stream, handles acknowledgements and retransmission, and puts the data into the socket's receive buffer.
- Your process is typically waiting in a system call such as
epoll_wait. The kernel wakes it, and it callsread/recvto copy the bytes from kernel space into its own memory.
Node's event loop (libuv), for example, is built on this: one thread watches many sockets with epoll and runs your callback when data arrives.
You can watch the system calls of a running process:
strace -f -e trace=network,read,write -p <pid>
Failure modes: socket backlog full, too many open file descriptors, CPU starvation. These show up under load as timeouts and connection resets, not as exceptions in your code.
8. The backend and the database
Your framework parses the HTTP request and runs it through a pipeline, usually like this:
router -> middleware -> auth/rate limit -> validation -> handler
Frontend validation is for UX. Backend validation is the security boundary, because anyone can send a request without your frontend.
The handler looks up the user. Backends normally use a connection pool instead of opening a new database connection per request:
SELECT id, password_hash FROM users WHERE email = $1;
Inside the database, that query is parsed, planned (the optimizer picks a strategy), and executed. With an index on email it does a B-tree lookup instead of scanning the table. Pages are read from the buffer cache in memory when possible, and only go to disk (filesystem, then SSD) on a cache miss. You can see the plan:
EXPLAIN ANALYZE SELECT id, password_hash FROM users WHERE email = 'user@example.com';
Then the password check. The database stores a hash, never the password:
// pseudocode (use your platform's well-tested library)
const ok = await verifyPasswordHash(submittedPassword, user.password_hash);
Use Argon2id, bcrypt, or scrypt. They are deliberately slow, so a leaked database is expensive to brute-force. That means part of your login latency is intentional. Also return the same generic error for "unknown email" and "wrong password", so the endpoint doesn't reveal which accounts exist.
If login creates a session row, that's a write. A write is recorded in the write-ahead log (WAL) and flushed to durable storage before the database confirms the commit. This is the one place in the trip where the physical disk really is on the critical path.
Failure modes: missing index (slow query), pool exhausted (requests queue up), deadlock or lock wait, a database that's down.
9. The response, and the way back
The backend returns something like:
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
{"success":true,"user":{"id":123,"name":"..."}}
It travels back through the same kinds of layers in reverse: your process calls write/send, the kernel segments it into packets, the NIC transmits it, and it goes back through the load balancer, the internet, your router, and your OS to the browser.
Then back in JavaScript, two details are worth knowing:
const response = await fetch("/api/login", { /* ... */ });
// The promise resolves when response HEADERS arrive, not the full body.
if (!response.ok) {
// fetch does NOT reject on 401/403/500. You must check this yourself.
throw new Error(`Login failed: ${response.status}`);
}
const user = await response.json(); // reading the body is a separate async step
setUser(user);
fetch only rejects on network-level failures (and CORS blocks). A 401 is a successful HTTP exchange as far as the promise is concerned.
10. State, DOM, pixels
setUser(user) doesn't update the screen directly. React schedules a render, runs your components, reconciles the result against the previous tree, and commits the DOM changes.
Then the browser's rendering pipeline takes over:
Style -> Layout -> Paint -> Raster -> Composite -> GPU -> Screen
Style calculation works out which CSS applies, layout computes sizes and positions, paint records what to draw, and the compositor assembles layers (often on the GPU) and presents them on the next display frame.
If this stage is slow, it's usually a long JavaScript task or an expensive layout, and it shows up as poor INP. The user sees the delay as "the button feels stuck", even though the server answered quickly.
Illustrative timeline
These numbers are made up to show proportions, not measurements. Real values depend on distance, connection reuse, and your backend:
| Stage | Typical order of magnitude |
|---|---|
Validation + handler + fetch() start |
about 1-5 ms |
| DNS (cache miss) | tens of ms |
| TCP + TLS (cold connection) | 1-3 round trips |
| Network RTT to server | 10-200+ ms depending on geography |
| Kernel + framework overhead | usually under a few ms |
| Password hash verification | tens to hundreds of ms (intentional) |
| Database query (indexed) | usually sub-ms to a few ms |
| React render + browser paint | a few to tens of ms |
For most real apps, network round trips and the backend dominate, not kernel overhead and not the database lookup itself.
What can fail at each layer
| Layer | Typical failure | Where you'd see it |
|---|---|---|
| HTML | Invalid field blocks submit | No submit event |
| JavaScript | Exception in handler | Console error |
| Browser | CORS block, mixed content | Console, red request in DevTools |
| DNS | Resolution failure | ERR_NAME_NOT_RESOLVED |
| TCP/QUIC | Timeout, reset | Stalled request |
| TLS | Bad or expired certificate | Browser security error |
| Network | Packet loss | Slow, retried requests |
| Proxy / LB | 502 / 503 / 504 | Status code, proxy logs |
| Server | Process crash, overload | 5xx, connection reset |
| API | Validation failure | 400 / 422 |
| Auth | Wrong credentials, no session | 401 / 403 |
| Database | Deadlock, slow query, pool exhausted | Timeouts, DB logs |
| Rendering | Long task | Laggy UI, poor INP |
How to debug the whole journey
Start at the top and move down until something looks wrong.
- DevTools -> Network tab. Click the request and open Timing. It splits the request into queueing/stalled, DNS lookup, initial connection, SSL, request sent, waiting (TTFB), and content download. Long "Waiting" usually means the server; long "Initial connection" means the network.
- Reproduce with curl to separate browser problems (CORS, cookies) from server problems:
curl -s -o /dev/null -w "dns: %{time_namelookup}\nconnect: %{time_connect}\ntls: %{time_appconnect}\nttfb: %{time_starttransfer}\ntotal: %{time_total}\n" \
-X POST https://api.example.com/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"test"}'
-
Check each layer directly:
digfor DNS,openssl s_clientfor certificates,traceroutefor the path. -
Server side: application logs, proxy logs, then database slow-query logs and
EXPLAIN ANALYZE. -
Last resort:
tcpdumpor Wireshark for packets (TLS-encrypted payloads won't be readable) andstracefor system calls.
The mental model
A Submit click isn't Button -> API -> Database. It's a stack of layers, each with its own failure modes and its own tools, and the response has to climb back up through every one of them.
You don't need to master all of them. Knowing which layer you're in is what turns "the form is broken" into a specific, checkable question.
Which layer has caused you the most confusing bug: CORS, a proxy timeout, a missing index, something else? And what tool finally showed you the problem?
Top comments (1)
You need to complete account verification.Link in the profile.