Fetch API: GET, POST, Uploads, and Timeouts
A practical Fetch API tutorial covering JSON requests, FormData uploads, AbortController timeouts, and SSE vs WebSockets.
The Fetch API handles nearly all browser networking: loading JSON, submitting forms, uploading files, and reporting analytics. This guide covers the production version of each task.
You will also learn where fetch stops being enough, and when server-sent events or WebSockets are the better tool.
How fetch handles GET and POST requests
A key detail: fetch only rejects on network failure. HTTP errors like 404 or 500 still resolve, so always check res.ok before parsing the body.
async function getJson(url, options) {
const res = await fetch(url, options);
if (!res.ok) throw new Error("GET failed: " + res.status);
return res.json();
}
async function postJson(url, payload) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error("POST failed: " + res.status + " " + text);
}
return res.json().catch(() => null);
}
const user = await getJson("/api/users/42");
const created = await postJson("/api/users", { name: "Milan" });What this example does
- getJson fetches a URL, rejects on HTTP errors, and parses JSON.
- postJson sends a JSON body with the correct Content-Type header.
- Failed POST responses include the status and body text for debugging.
How to add timeouts with AbortController
Without a timeout, a stalled request can leave spinners running forever. An AbortController cancels the request after a fixed duration.
async function fetchWithTimeout(url, ms = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await getJson(url, { signal: controller.signal });
} catch (err) {
if (err.name === "AbortError") throw new Error("Request timed out");
throw err;
} finally {
clearTimeout(timer);
}
}This pattern fits dashboards, search boxes, and mobile networks where hanging requests are common. The timer is always cleared so healthy requests cost nothing extra.
How to upload files with FormData
File uploads use FormData. The browser sets the multipart boundary automatically, so do not set Content-Type by hand or the upload will fail.
async function uploadAvatar(file) {
if (!file.type.startsWith("image/")) throw new Error("Only images allowed");
if (file.size > 5 * 1024 * 1024) throw new Error("Max file size is 5MB");
const form = new FormData();
form.append("avatar", file, file.name);
const res = await fetch("/api/upload", { method: "POST", body: form });
if (!res.ok) throw new Error("Upload failed: " + res.status);
return res.json();
}Client-side type and size checks run before any bytes are sent. This saves bandwidth and gives users an instant, understandable error.
How to send analytics reliably with sendBeacon
Analytics sent with fetch during page close are frequently dropped. The browser queues navigator.sendBeacon payloads so they survive navigation and tab close.
function trackPageHide() {
const payload = JSON.stringify({ path: location.pathname, t: Date.now() });
const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon("/api/analytics", blob);
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") trackPageHide();
});SSE vs WebSockets: which realtime tool to choose
Server-sent events stream updates in one direction with automatic reconnection. They suit feeds, build progress, and notifications.
WebSockets allow full two-way messaging for chat and collaboration, but reconnection, heartbeats, and auth become your responsibility.
const source = new EventSource("/api/events");
source.onmessage = (e) => renderEvent(JSON.parse(e.data));
source.onerror = () => console.warn("SSE reconnecting...");
const ws = new WebSocket("wss://example.com/realtime");
ws.addEventListener("open", () => ws.send(JSON.stringify({ type: "hello" })));
ws.addEventListener("message", (e) => handleMessage(JSON.parse(e.data)));
ws.addEventListener("close", () => scheduleReconnect());Real-world use cases
- Search and listings: GET with timeouts plus graceful empty states.
- Profiles and settings: POST JSON with field-level error messages.
- Media and onboarding: FormData uploads with progress and size limits.
Best practices for production APIs
- Keep one API client with auth headers, timeouts, and normalized errors.
- Retry idempotent GET requests with backoff; avoid blindly retrying POSTs.
- Validate file type and size before upload, then validate again on the server.
- Prefer SSE or polling before adding the operational cost of WebSockets.
Common mistakes to avoid
- Skipping the res.ok check and treating HTTP errors as valid data.
- Setting Content-Type manually on FormData uploads.
- Using WebSockets for simple one-way updates that SSE handles better.
Frequently asked questions
Why did my fetch succeed with a 500 error?
Because fetch resolves for every completed HTTP response. Inspect res.ok or res.status to detect server errors.
When should POST requests be retried?
Only when the operation is idempotent or carries an idempotency key. Retrying a payment or creation endpoint blindly can duplicate the action.
Key takeaways
- Always check res.ok and normalize API errors in one place.
- Add timeouts to user-facing requests and use sendBeacon for analytics.
- Choose SSE for server pushes and WebSockets only for true two-way needs.