Six Layers of Protection Every Frontend fetch() Call Is Missing
When I opened the request code in the project, I saw — no timeout, no cancellation, no retry, errors swallowed silently. This isn't calling an API; this is running naked.
Most frontend projects' API requests look like this:
const res = await fetch('/api/users');
const data = await res.json();
setUsers(data);
Three lines of code, clean and neat. But these three lines are a ticking time bomb in production — what if the API times out? What if the component unmounts while still calling setState? What if the user quickly switches pages and old data overwrites new data?
I spent an afternoon "hardening" the request code in my project, layering on protections one by one. Here is the complete record of these 6 layers of protection, each with before-and-after code comparisons.
Layer 1: Error Fallback
The most basic layer, but most projects don't do it well.
Naked version:
async function getUsers() {
const res = await fetch('/api/users');
const data = await res.json();
return data;
}
What's the problem? fetch only rejects on network failures. If the server returns 500, 404, or 403, fetch still considers the request "successful" and resolves. The data you get might be an HTML error page.
With the first layer of protection:
async function getUsers() {
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return await res.json();
} catch (err) {
console.error('Failed to get user list:', err);
return null;
}
}
res.ok is true only for status codes 200-299. This single line blocks all non-2xx responses.
Layer 2: Timeout Control
fetch has no built-in timeout. If the server hangs and doesn't respond, fetch will wait forever. The user just sees a page that keeps spinning.
Naked version:
const res = await fetch('/api/users');
With the second layer of protection:
const res = await fetch('/api/users', {
signal: AbortSignal.timeout(8000)
});
Done in one line. AbortSignal.timeout(8000) is a native API — it automatically cancels the request after 8 seconds and throws a TimeoutError. No need for the old manual AbortController + setTimeout pattern.
Combined with the first layer:
async function getUsers() {
try {
const res = await fetch('/api/users', {
signal: AbortSignal.timeout(8000)
});
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'TimeoutError') {
console.error('Request timed out');
} else {
console.error('Request failed:', err);
}
return null;
}
}
Layer 3: Loading and Error States
What does the user see while the API is requesting? If nothing is displayed, the user will frantically click buttons; if an error is silently swallowed, the user thinks the page is normal and the data is correct.
Naked version:
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data));
}, []);
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
During the request, you see an empty list; on error, you still see an empty list. The user can't tell the difference between "still loading" and "really no data."
With the third layer of protection:
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch('/api/users', { signal: AbortSignal.timeout(8000) })
.then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(data => setUsers(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!users.length) return <div>No data</div>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Three state flags — loading, error, data — form the minimum complete state machine for API requests. Missing any one of them creates a blind spot in the user experience.
Layer 4: Request Cancellation
A user opens page A, the API is still requesting, and the user switches to page B. Page A's component has unmounted, but the request is still in flight. When the response returns, React tries to call setState on a component that no longer exists — console warnings, and in severe cases, memory leaks.
Naked version:
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data));
}, []);
With the fourth layer of protection:
useEffect(() => {
const controller = new AbortController();
fetch('/api/users', { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(data => setUsers(data))
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
Call controller.abort() inside the useEffect cleanup function, and the request is automatically cancelled when the component unmounts. Note that you must filter out AbortError in the catch block — this is a normal cancellation, not an error.
This layer of protection also has a hidden benefit: if the dependencies of useEffect change and cause it to re-execute, the old request is automatically cancelled and won't fight with the new one.
Layer 5: Race Condition Protection
A user quickly types "Zhang San" into a search box, triggering three requests:
- "Zhang" → Request A sent
- "Zhang San" → Request B sent
- The result for "Zhang" comes back first → Page shows results for "Zhang"
- The result for "Zhang San" comes back → Page flashes and changes to results for "Zhang San"
If the network is unstable, steps 3 and 4 might reverse — the user searched for "Zhang San", but the page shows results for "Zhang". This is a race condition.
Naked version:
useEffect(() => {
fetch(`/api/search?q=${keyword}`)
.then(res => res.json())
.then(data => setResults(data));
}, [keyword]);
With the fifth layer of protection:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${keyword}`, {
signal: controller.signal
})
.then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
});
return () => controller.abort();
}, [keyword]);
The key is return () => controller.abort(). When keyword changes, React first executes the previous cleanup function (cancelling the old request), then executes the new effect (sending a new request). The old request is cancelled and won't come back to overwrite new data.
Layers 4 and 5 use the same mechanism (AbortController + cleanup), but solve two different problems: Layer 4 prevents memory leaks, Layer 5 prevents data corruption.
Layer 6: Automatic Retry
Mobile users have unstable networks; occasional disconnections are common. If a single request failure immediately throws an error, the user experience is poor. But retries shouldn't be mindless — only network errors and temporary server errors (5xx) are worth retrying; client errors (4xx) will produce the same result no matter how many times you retry.
Naked version:
const res = await fetch('/api/users');
With the sixth layer of protection:
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(url, {
...options,
signal: options.signal || AbortSignal.timeout(8000)
});
if (!res.ok) {
if (res.status >= 500 && i < retries) {
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
continue;
}
throw new Error(`Request failed: ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw err;
if (i === retries) throw err;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
Three details:
- Only retry 5xx and network errors: 4xx is not retried, avoiding pointless wasted requests
- Incremental delay: Wait 1 second the first time, 2 seconds the second time, 3 seconds the third time (simplified backoff)
- AbortError is not retried: Requests actively cancelled by the user should not restart themselves
API Request Protection Layer Quick Reference
| Protection Layer | Problem Solved | Core Code | Consequence of Omission |
|---|---|---|---|
| Error Fallback | 500/404 treated as success | if (!res.ok) throw |
Error data used as correct data |
| Timeout Control | Request waits forever | AbortSignal.timeout(8000) |
Page spins indefinitely |
| State Management | User can't see status | loading + error + data tri-state | Can't distinguish "loading" from "no data" |
| Request Cancellation | setState after component unmount | controller.abort() + cleanup |
Memory leak + console warnings |
| Race Condition Protection | Old data overwrites new data | Cleanup cancels previous request | Search results don't match input |
| Automatic Retry | Single network blip causes failure | Only 5xx/network errors + incremental delay | Occasional network fluctuation directly errors out |
One-sentence summary: Every time you write fetch, first ask yourself — what if it times out, what if it errors, what if it's cancelled, what if it's duplicated. If you can't answer, you're running naked.
These 6 layers don't need to be added all at once. Priority decreases from top to bottom:
- Must add: Error fallback + Timeout control + Loading state (first three layers)
- Recommended: Request cancellation + Race condition protection (layers 4 and 5)
- Situational: Automatic retry (layer 6, recommended for mobile projects)
If you use React, react-query (TanStack Query) or SWR have all 6 layers built in, no need to write them manually. But understanding the principles before using a wrapper library versus using it mindlessly — the difference in debugging efficiency is 10x.
Top 2 from juejin.cn, machine-translated. The original thread is authoritative.
Can you wrap it up? A simple request ends up as long as a water snake's spring. Do we still need to write business code?
[Like]