You click "Login." A spinner appears. A moment later, you're either inside your dashboard or staring at an error message.
From a user's perspective, almost nothing happened. But underneath that button click, your browser created an HTTP request, that request travelled across a network to a server, the server received it, validated the data, possibly queried a database, performed some business logic, created a response, and sent that response back to the browser. Then JavaScript had to process the response, update application state, and tell React to render the new UI.
As frontend developers, we write things like const response = await fetch("/api/login"); so often that it's easy to forget how much is happening behind that one line.
It Starts With an Event
The process usually starts with some user interaction — a button click, a form submit. React calls your handler, and inside that function you eventually call fetch(). This is the moment your frontend begins communicating with the server.
What Is fetch() Actually Doing?
fetch() is a Web API provided by modern browsers. At a high level, you're telling the browser: "Make a request to this URL and give me the result." But fetch() doesn't immediately give you the server's response. It gives you a Promise.
const result = fetch("/api/users");
console.log(result); // Promise { <pending> }Why? Because the server hasn't responded yet. The browser has to send the request, wait for the server, receive the response, and make that response available to JavaScript. That takes time.
Why fetch() Returns a Promise
Network requests are asynchronous. If JavaScript stopped doing anything until the server responded, the entire browser would freeze. Instead, JavaScript starts the operation and continues doing other work. A Promise represents a value that will become available in the future — pending, then either fulfilled or rejected.
This is why await is so useful: it pauses the async function until the Promise settles, then gives you its result — without freezing the entire browser.
The HTTP Request
fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "david@example.com",
password: "secret123",
}),
});An HTTP request contains several important pieces:
- Method — tells the server what kind of operation (GET, POST, PUT, PATCH, DELETE)
- URL — the address the request should go to
- Headers — metadata about the request (Content-Type, Authorization, etc.)
- Body — the actual data, serialized as JSON with JSON.stringify()
What the Server Does
Your JavaScript does not directly call a database. It sends a request to a server. The backend might validate input, find the user, check the password, create a session or token, and build a response. The frontend doesn't necessarily know how any of this happens — that's the point of an API. It gives the frontend a controlled interface to backend functionality.
HTTP Status Codes
The server's response always includes a status code. These aren't random numbers — they describe what happened:
| Range | Category | Examples |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 4xx | Client/request problem | 400 Bad Request, 401 Unauthorized, 404 Not Found |
| 5xx | Server problem | 500 Internal Server Error, 502 Bad Gateway |
A frontend developer should not treat every non-200 response as the same error. The status code contains useful information.
A Critical fetch() Gotcha
A fetch() Promise can fulfill even when the server returns a 4xx or 5xx status. This surprises many developers. If the server responds with 401 Unauthorized, the fetch Promise itself still fulfills. You need to inspect the response:
if (!response.ok) {
throw new Error("Request failed");
}Two Async Steps
Even after fetch() resolves, the response body isn't automatically a JavaScript object. You need to read it:
const response = await fetch(url); const data = await response.json(); // second async step
Where React Comes In
React doesn't make the API request happen. JavaScript makes the request. The API responds. Then we update state, and React sees the state change and re-renders:
const [user, setUser] = useState<User | null>(null);
async function login() {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(credentials),
});
const data = await response.json();
setUser(data.user); // → React schedules update → UI re-renders
}Handling All the States
async function login() {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) throw new Error("Login failed");
const data = await response.json();
setUser(data.user);
} catch {
setError("Unable to log you in");
} finally {
setLoading(false);
}
}Where Do Axios and TanStack Query Fit?
They don't replace HTTP — they sit on top of the underlying browser/network capabilities. Axios gives you a convenient client API. TanStack Query manages server state around those requests: caching, refetching, stale data, retries, optimistic updates. They solve different problems:
- fetch / Axios — make HTTP requests
- TanStack Query — manage server state around those requests
- Redux / Zustand — manage client/application state
Abstractions hide complexity. They don't remove it. The better you understand the layers underneath your abstractions, the better you can debug them.
The Mental Model
When something breaks, think in layers: Is the UI wrong? Is the state stale? Did the query fail? Did the HTTP request fail? Did the server reject the request? Did the backend business logic fail? Did the database operation fail?
The next time you write const response = await fetch("/api/users");, remember you started a chain of events: user interaction → JavaScript → HTTP request → network → backend → business logic → database → HTTP response → browser → JavaScript → application state → React → UI.
Once you understand those layers, APIs stop feeling like a magical black box. They're simply a series of systems communicating with one another.