DEV Community

Cover image for " What Actually Happens When You Click \"Submit\"? A Request From Browser to Database and Back "
Shrey Saraswat
Shrey Saraswat

Posted on

" What Actually Happens When You Click \"Submit\"? A Request From Browser to Database and Back "

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>
Enter fullscreen mode Exit fullscreen mode

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),
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two points that trip people up:

  • CORS is enforced by the browser. curl will 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:

  1. Its own DNS cache
  2. The OS resolver cache
  3. A recursive resolver (your ISP's, or something like 1.1.1.1 or 8.8.8.8)
  4. 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
Enter fullscreen mode Exit fullscreen mode

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  ------------------>
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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":"..."}
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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 calls read/recv to 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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":"..."}}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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.
  2. 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"}'
Enter fullscreen mode Exit fullscreen mode
  1. Check each layer directly: dig for DNS, openssl s_client for certificates, traceroute for the path.
  2. Server side: application logs, proxy logs, then database slow-query logs and EXPLAIN ANALYZE.
  3. Last resort: tcpdump or Wireshark for packets (TLS-encrypted payloads won't be readable) and strace for 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)

Collapse
 
devantibot profile image
DEV ANTIBOT •

You need to complete account verification.Link in the profile.