HTTP Status Codes: What Your React App Sees From Every Response
Understand the five categories of HTTP status codes and the specific codes a React frontend regularly encounters, and write code that branches correctly on each one instead of treating every response the same way.
Every time your React app calls fetch or an HTTP client like axios, the server answers with a three-digit status code before it answers with any data. That code is not decoration — it is the contract that tells your UI whether to render data, show a login form, retry, back off, or show an error page. A shocking number of production bugs come from code that only checks 'did the promise resolve?' instead of reading the status code. This lesson walks through every category (2xx, 3xx, 4xx, 5xx) and the specific codes you will see constantly, with the exact frontend behavior each one should trigger.
The Five Categories, At a Glance
HTTP status codes are grouped by their first digit. The category alone tells you the general shape of what happened, even before you read the exact number. React apps should be written so that unhandled/unexpected codes within a category still degrade gracefully — for example, any unrecognized 4xx should be treated as 'client-side problem, do not retry automatically', and any unrecognized 5xx as 'server-side problem, safe to retry'.
Note that '1xx' (informational, e.g. 100 Continue) responses are handled entirely by the browser's networking layer — your fetch promise never resolves with a 1xx status, so this lesson does not cover that category further.
• 2xx Success — the request worked; the body usually contains the resource or confirmation. • 3xx Redirection — the client must follow a different URL to complete the request; browsers and fetch mostly handle this transparently. • 4xx Client Error — the request itself was wrong (bad auth, bad input, missing resource); retrying the exact same request will fail again. • 5xx Server Error — the server failed while processing an otherwise-valid request; retrying later may succeed.
2xx — Success
2xx codes mean the server did what you asked. Most React data-fetching code is written for the 'happy path' of 200, but 201 and 204 show up constantly in CRUD apps and need slightly different handling because their response bodies differ.
• 200 OK — the standard success response for GET, PUT, or PATCH. The body contains the requested or updated resource. This is what a component's useEffect fetch, or a React Query queryFn, expects on the normal path. • 201 Created — returned after a successful POST that created a new resource (e.g. submitting a new comment, creating a new user). The body usually contains the newly created object, often including a server-assigned id. Frontend code should read this id and, e.g., navigate to /posts/${newPost.id} or optimistically update a list with the real (not temporary) id. • 204 No Content — the request succeeded but there is intentionally no response body. Common after DELETE requests or some PUT/PATCH updates. Calling .json() on a 204 response will throw, because there is no JSON to parse — your fetch wrapper must check for 204 (or an empty body) before attempting to parse it.
3xx — Redirection
3xx codes tell the client 'go somewhere else for this'. When using fetch, the browser follows most redirects automatically (redirect: 'follow' is the default), so your JavaScript never actually sees a 301/302/307 status code in response.status unless you explicitly set redirect: 'manual'. Still, understanding them matters because your API server, your CDN, or your auth flow may use them, and misconfigured CORS + redirects is one of the most common 'why is my fetch silently failing' bugs.
304 is different: it is the one 3xx code your frontend caching logic actively cares about, because it is involved in conditional requests for caching, not page redirection.
• 301 Moved Permanently — the resource now lives at a new URL forever. Search engines and browsers update their records to the new URL. If your API base URL changes (e.g. api.example.com → api.v2.example.com), the old endpoint might 301 to the new one. • 302 Found — a temporary redirect (e.g. 'log in first, then we'll send you back'). OAuth login flows use 302 heavily: your app redirects the browser to the identity provider, which 302s back to your app's callback URL with a token/code in the query string. • 304 Not Modified — sent in response to a conditional GET (one that includes an If-None-Match or If-Modified-Since header) when the resource hasn't changed since the client's cached copy. The browser's HTTP cache handles this transparently for you — it serves the cached body and your fetch promise still resolves with the data, even though the network status was 304. This is why static assets and some API GETs feel instant on repeat visits.
4xx — Client Error (You Did Something the Server Won't Accept)
4xx means the problem is on the request side — bad credentials, malformed input, a resource that doesn't exist, or a request the client isn't allowed to make. The defining rule for frontend code: never blindly retry a 4xx with the exact same request; it will fail identically every time. Instead, fix the request (re-authenticate, correct the input) or show the user an actionable message.
• 400 Bad Request — the request is malformed (e.g. invalid JSON body, missing required field, wrong data type). Show the user a validation-style error, or log it as a client bug if the request was built by your own code. • 401 Unauthorized — the request has no valid credentials, or the credentials expired. This is the classic 'your session/token has expired' response. A well-built React app intercepts 401 globally (e.g. in an axios interceptor or a fetch wrapper), attempts a silent token refresh if possible, and otherwise redirects to /login. • 403 Forbidden — the credentials are valid, but the authenticated user isn't allowed to do this action (e.g. a regular user calling an admin-only endpoint). Unlike 401, re-authenticating won't help — show a permissions error, don't redirect to login. • 404 Not Found — the specific resource doesn't exist (e.g. /api/posts/9999 when post 9999 was deleted). In a React Router app, this commonly maps to rendering a 'Post not found' component rather than a generic error boundary. • 409 Conflict — the request conflicts with the current state of the resource (e.g. trying to create a user with an email that's already taken, or a version mismatch in optimistic-concurrency updates). The UI should surface the conflict specifically, e.g. 'That email is already registered.' • 422 Unprocessable Entity — the request is well-formed (valid JSON, right shape) but fails semantic/business validation (e.g. password too short, end date before start date). APIs commonly return a structured body of per-field errors with 422, which React forms map directly onto field-level error messages. • 429 Too Many Requests — the client has been rate-limited. The response often includes a Retry-After header. Frontend code should back off — disable the submit button, show 'please wait Ns', and avoid hammering the endpoint. This is common on search-as-you-type or aggressive polling if not debounced.
5xx — Server Error (The Server Broke)
5xx means the request was probably fine, but the server failed to process it — a bug, a crashed dependency, an overloaded service, or a timeout somewhere upstream. Unlike 4xx, 5xx errors are usually safe to retry, ideally with exponential backoff, since the condition may be transient.
• 500 Internal Server Error — a generic catch-all for 'the server threw an unhandled exception'. Show a generic 'something went wrong, please try again' message; don't try to parse specifics out of it unless your API guarantees a structured error body. • 502 Bad Gateway — a reverse proxy or load balancer got an invalid response from an upstream server (e.g. your API gateway couldn't reach the actual application server). To the frontend this looks like any other server failure; retry logic treats it the same as 500/503/504. • 503 Service Unavailable — the server is temporarily unable to handle the request, often due to overload or planned maintenance. May include a Retry-After header, similar to 429. Good candidate for automatic retry with backoff. • 504 Gateway Timeout — a reverse proxy gave up waiting for a response from an upstream server. Frequently seen with slow database queries or long-running requests. Consider whether the operation should be async (return 202-style 'processing' semantics) instead of a synchronous request the gateway will time out on.
A Fetch Wrapper That Branches on Status Category
Checking the status range (>= 200 && < 300) rather than a single number covers every current and future 2xx code without extra branches. 204 is special-cased before calling .json() because an empty body is not valid JSON and .json() would throw. The retryable flag on HttpError lets a single generic retry utility decide whether to retry, without re-deriving status logic at every call site.
// A minimal, typed fetch wrapper that treats status codes as first-class data,
// not an afterthought. Real apps build on exactly this shape.
interface ApiError {
status: number;
message: string;
fieldErrors?: Record<string, string>;
retryable: boolean;
}
class HttpError extends Error implements ApiError {
status: number;
fieldErrors?: Record<string, string>;
retryable: boolean;
constructor(status: number, message: string, fieldErrors?: Record<string, string>) {
super(message);
this.status = status;
this.fieldErrors = fieldErrors;
// 4xx: the client sent something wrong -> not retryable (except 429).
// 5xx: the server failed -> retryable.
this.retryable = status === 429 || (status >= 500 && status < 600);
}
}
async function apiFetch<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
// 2xx: success family
if (response.status >= 200 && response.status < 300) {
if (response.status === 204) {
// No Content: there is nothing to parse.
return undefined as T;
}
return (await response.json()) as T;
}
// Everything else is an error path; try to pull a structu
…Code Examples
This form treats 422 and 409 as distinct, meaningful outcomes rather than generic failures: 422 gives per-field validation messages straight from the server, while 409 tells the user specifically that the email is taken. Any other error (network failure, 500, 503) falls through to a generic form-level banner, because the frontend has no specific guidance to give the user for those.
if (err instanceof HttpError && err.status === 422 && err.fieldErrors) {
A 401 doesn't always mean 'log out immediately' — modern apps first try a silent refresh using a longer-lived refresh token. refreshPromise deduplicates simultaneous refresh attempts so five components 401-ing at once don't trigger five parallel refresh calls. Only if the refresh itself fails does the app force a full redirect to /login.
Common mistake: A common bug is retrying the refreshed request infinitely if the new token is also rejected — this code deliberately retries only once, then lets the error propagate.
For retryable failures — 429 and any 5xx — this wrapper retries up to three times with exponential backoff (500ms, 1000ms, 2000ms) instead of hammering an already-struggling or rate-limiting server. A production version would read a Retry-After response header when present and prefer that exact delay over a guessed backoff. Expected result: Resolves with data after 1–3 attempts, or rethrows the HttpError after the 4th failed attempt.
function SignupForm() {
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({});
const [submitting, setSubmitting] = React.useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setFieldErrors({});
const formData = new FormData(e.currentTarget);
try {
await apiFetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(formData)),
});
// 201 Created path handled inside apiFetch; navigate away on success.
} catch (err) {
if (err instanceof HttpError && err.status === 422 && err.fieldErrors) {
// 422: semantic validation failure -> map each field error onto the form.
setFieldErrors(err.fieldErrors);
} else if (err instanceof HttpError && err.status === 409) {
// 409: e.g. "email already registered" -> a specific, actionable message.
setFieldErrors({ email: err.message });
} else {
// Anything else (network failure, 5xx) -> generic banner, not field-level.
setFieldErrors({ _form: "Someth
…let refreshPromise: Promise<void> | null = null;
async function apiFetchWithAuth<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
try {
return await apiFetch<T>(input, addAuthHeader(init));
} catch (err) {
if (err instanceof HttpError && err.status === 401) {
// Deduplicate concurrent refresh attempts if multiple requests 401 at once.
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null;
});
try {
await refreshPromise;
} catch {
// Refresh itself failed (refresh token expired) -> force a real login.
window.location.assign("/login");
throw err;
}
// Retry the original request exactly once with the new token.
return apiFetch<T>(input, addAuthHeader(init));
}
throw err;
}
}async function fetchWithBackoff<T>(input: RequestInfo, init?: RequestInit, attempt = 0): Promise<T> {
try {
return await apiFetch<T>(input, init);
} catch (err) {
if (err instanceof HttpError && err.retryable && attempt < 3) {
const delayMs = attempt === 0 ? 500 : 500 * 2 ** attempt; // simple exponential backoff
await new Promise((resolve) => setTimeout(resolve, delayMs));
return fetchWithBackoff<T>(input, init, attempt + 1);
}
throw err;
}
}Status Code Decision Flow in a React Data Layer
Status category determines the branch; specific codes inside 4xx/5xx determine the exact UI response.
fetch(url) resolves with a Response
|
v
+-------------------------+
| response.status? |
+-------------------------+
| | | |
2xx 3xx 4xx 5xx
| | | |
parse body (usually client server
& return it auto- fixes problem:
(204 -> no followed request retry w/
body to by the OR backoff,
parse) browser) re-auth else show
(401) generic
or error
show
message
(400/403/
404/409/
422/429)Important Rules
Never assume a resolved fetch promise means success — a 404 or 500 still resolves the promise; you must check response.ok or response.status explicitly.
Do not call .json() on a response that may be 204 (or otherwise empty) without guarding for it first.
Treat 4xx as 'fix the request' and 5xx (plus 429) as 'safe to retry, ideally with backoff' — this single rule drives almost all frontend error-handling logic.
401 means 're-authenticate'; 403 means 'you are authenticated but not allowed' — never redirect to login on a 403, since logging in again won't grant permission.
Prefer structured error bodies (field-level messages on 422, a machine-readable code on other errors) over parsing human-readable strings.
Why This Matters at Scale
In a production SaaS dashboard, a support ticket came in: 'the app logs me out randomly while I'm working.' The root cause was a fetch wrapper that treated every non-200 response, including transient 503s during a deploy, as a 401 and force-redirected to /login. Once the team rewrote the wrapper to check the exact status code — 401 triggers refresh-then-redirect, 5xx triggers a toast plus retry, 429 triggers backoff — the 'random logout' tickets disappeared entirely. Precise status-code handling isn't academic; it's the difference between an app that feels reliable and one that randomly discards user work.
const data = await fetch(url).then(r => r.json());
const res = await fetch(url); if (!res.ok) throw new HttpError(res.status, ...); const data = await res.json();
fetch only rejects on network failure (e.g. no connectivity, DNS failure) — a 404 or 500 still resolves successfully and .json() will happily parse whatever error body the server sent as if it were valid data, corrupting your app state.
if (err.status === 401) { window.location.href = '/login'; } if (err.status === 401) { /* try refresh first */ } else if (err.status === 403) { showPermissionError(); }
Redirecting to login on every 401 without attempting a token refresh logs users out unnecessarily on transient token expiry, and conflating 401 with 403 sends already-authenticated users to a login page that won't solve their permissions problem.
catch (err) { retry(); } // retries on every error catch (err) { if (err.retryable) retry(); else showError(err); }
Blindly retrying on any error will loop forever on a 400 or 404 (which will never succeed by re-sending the same request) and can amplify load on an already-overwhelmed server during a 5xx incident.
[multiple-choice] Your React app calls DELETE /api/posts/42 and the server returns 204. What should your fetch wrapper do? A. Call response.json() as usual since 204 is a success code B. Treat it as a success but skip parsing a JSON body, since 204 means there is no content C. Treat it as an error because there's no data returned D. Retry the request because 204 is incomplete
B. Treat it as a success but skip parsing a JSON body, since 204 means there is no content — 204 No Content is a success status that deliberately has no response body. Calling .json() on it will throw because there's nothing to parse — code must special-case it and simply treat the operation as succeeded.
[multiple-choice] A request returns 403 Forbidden. What is the correct frontend response? A. Redirect the user to the login page, since they're not authenticated B. Silently retry the request with exponential backoff C. Show a 'you don't have permission' message, since the user IS authenticated but not authorized for this action D. Treat it the same as a 401
C. Show a 'you don't have permission' message, since the user IS authenticated but not authorized for this action — 403 means the server recognized valid credentials but the authenticated user lacks permission for this specific action. Redirecting to login (appropriate for 401) won't fix anything, since logging in again grants the same insufficient permissions.
[multiple-choice] Which pair of codes should generally trigger an automatic retry with backoff, rather than an immediate user-facing error? A. 400 and 404 B. 401 and 403 C. 429 and 503 D. 301 and 302
C. 429 and 503 — 429 (Too Many Requests) and 503 (Service Unavailable) are both explicitly transient conditions — the server is asking the client to slow down or wait — and both often include a Retry-After header, making them ideal candidates for automatic retry with backoff.
[true-false] True or false: a fetch() promise rejects when the server responds with a 500 status code. A. True B. False
B. False — False. fetch() only rejects on network-level failures (e.g. no connection, CORS block, DNS failure). A 500, 404, or any other HTTP status still resolves the promise successfully — you must check response.ok or response.status yourself to detect the error.
Extend apiFetch so that a 403 response shows a toast notification reading 'You do not have permission to perform this action' and does NOT redirect to /login, while a 401 attempts a token refresh and only redirects if the refresh itself fails. Then add handling so a 429 response reads the Retry-After header (in seconds) and uses that exact value for the backoff delay instead of a fixed guess.
Add a case 403: branch that calls showToast(...) without touching window.location. Keep 401 routed through the refresh-then-retry logic already shown. In the 429 branch, read const retryAfter = response.headers.get('Retry-After'); const delayMs = retryAfter ? Number(retryAfter) * 1000 : defaultBackoff; before scheduling the retry.
For the Retry-After header, use response.headers.get('Retry-After'); it may be seconds as a number or an HTTP date string, so handle at least the numeric-seconds case.