How a Full-Stack Web Application Handles an API Request
A practical walkthrough of what happens between a browser sending a request and the application returning a response.
Every interaction in a web application starts with the same primitive: an HTTP request. Understanding the path a single request travels — from the browser, through the server, into the database, and back — makes it much easier to debug slow pages, secure endpoints, and design APIs that scale.
This guide follows one request end to end. It stays deliberately general: the exact layers depend on your hosting and framework, but the sequence is stable across most full-stack systems.
The request lifecycle at a high level
At its core, handling a request means: receive it, decide what it wants, gather any data it needs, and return a response.
A request is only ever as fast as its slowest hop. Profiling usually means finding which hop in this chain is waiting the longest.
Where the request goes first
The browser opens a TCP connection and sends a request line plus headers. The first systems it meets are typically not your application code:
| Layer | Responsibility |
|---|---|
| DNS | Resolves the domain to an IP address. |
| Load balancer / CDN | Distributes traffic and serves cached assets. |
| Web server | Terminates TLS and forwards to the app process. |
| Application | Routes the request to a handler. |
You can inspect exactly what a request sends from the terminal:
curl -i https://api.example.dev/v1/users/42 \
-H "Authorization: Bearer <token>"
The -i flag includes the response headers, which is where status codes and
content types live.
Routing to a handler
Once the request reaches the application, the framework matches its method and path to a handler. Below is a representative handler written in TypeScript. It validates input, fetches data, and returns a typed response.
import type { Request, Response } from "express";
import { getUserById } from "../data/users.js";
export async function getUser(req: Request, res: Response) {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "Invalid user id" });
}
const user = await getUserById(id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
return res.status(200).json({ user });
}
Notice the two early exits: a 400 for malformed input and a 404 when the
resource does not exist. Returning early keeps the happy path unindented and
readable.
Fetching data from the database
If the handler needs stored data, it issues a query. Using a parameterized statement is the baseline for both correctness and safety, since the driver handles escaping values for you.
SELECT id, email, display_name, created_at
FROM users
WHERE id = $1
LIMIT 1;
A few practical notes on the data hop:
- Open a connection from a pool rather than per request.
- Select only the columns you render.
- Add an index on columns used in
WHEREandORDER BY.
Shaping the response
Handlers return data through a serializer so the public shape is explicit and
stable. The display_name field, for example, may be assembled from columns
rather than stored directly.
{
"user": {
"id": 42,
"displayName": "Ada Lovelace",
"createdAt": "2026-01-08T10:15:00.000Z"
}
}
The browser receives this with a 200 OK status and a content-type: application/json header, then the client code parses and renders it.
Putting it together
A single request is really a chain of small, testable hops. When something is slow or broken, isolate one hop at a time — the network, the routing, the query, or the serialization.
For a broader index of guides like this one, browse the full blog. For the underlying protocol details, the MDN HTTP overview is a reliable reference.