Mastering the Fetch API – Modern HTTP Requests in JavaScript
The Fetch API is how JavaScript talks to servers. It replaced XMLHttpRequest a while back, and it’s built into every modern browser (and Node.js since v18). If you’ve done any frontend work in the last few years, you’ve almost certainly used it — but there’s a good chance you’re only scratching the surface. I want to walk through the patterns I end up using in real projects, not just the hello-world examples.
The Basics
A fetch request returns a Promise that resolves to a Response object:
1 | |
The .json() method reads the response body and parses it as JSON. It returns a Promise, which is why there are two .then() calls. Other body-reading methods include .text(), .blob(), .formData(), and .arrayBuffer().
The async/await version is cleaner and is preferred in modern code:
1 | |
The critical detail that trips up beginners is that fetch() only rejects the Promise on network errors — the request could not reach the server at all. HTTP error responses like 404 or 500 do not cause a rejection. The fetch succeeds, and you get a Response object with ok: false. You must check response.ok or response.status yourself. This is by design, not a bug — the server successfully responded, just with an error status.
Sending Data
Beyond GET requests, fetch accepts a second argument: an options object. The most common options are method, headers, and body:
1 | |
For file uploads, use FormData instead of JSON. Do not set the Content-Type header when using FormData — the browser sets it automatically with the correct multipart boundary:
1 | |
Aborting Requests
In single-page applications, a user might navigate away from a page before a fetch completes. Continuing to process the response wastes resources and can cause state updates on unmounted components. AbortController solves this:
1 | |
The AbortError is thrown when the request is cancelled. It is important to distinguish it from actual network errors so you do not log it as a real failure.
A common pattern in React: abort a fetch when a component unmounts:
1 | |
The cleanup function returned from useEffect calls controller.abort(), which aborts any in-flight request when the component unmounts or the dependency changes. This prevents the classic “setState on unmounted component” warning.
Custom Request Objects
For reusable request configurations, construct a Request object:
1 | |
Request objects shine when you want a base configuration you can tweak per-call. Instead of passing method, headers, and body to every invocation, you build a Request once and override what you need. I find this especially handy in larger codebases where you’re hitting the same API from a dozen different places.
Handling Different Response Types
Not every endpoint returns JSON. Handle each type appropriately:
1 | |
The response body is a ReadableStream under the hood. This matters when you’re dealing with large payloads — you can process chunks as they arrive instead of holding everything in memory. Server-Sent Events and streaming JSON responses are becoming more common (think ChatGPT-style output), and fetch gives you the low-level primitives to handle them natively.
Error Handling Strategy
A production-grade fetch wrapper should handle network errors, HTTP errors, and timeouts consistently:
1 | |
I’ve been using some version of this wrapper in every project for years. It handles timeouts, gives you consistent error objects, and parses JSON automatically. You can layer on retries, deduplication, or caching later — but the real win is that every component and every page in your app talks to the backend the same way. One wrapper, one mental model for errors, one place to tweak when requirements change.
Common Patterns
For parallel independent requests, use Promise.all:
1 | |
For dependent requests where one call depends on the result of another, do not use Promise.all. Chain them sequentially:
1 | |
For uploading with progress tracking, fetch does not directly support upload progress events. Use XMLHttpRequest for that specific use case, or use the fetch ReadableStream for download progress tracking.
Fetch is one of those APIs where the basics take ten minutes to learn, but the edge cases — timeouts, cancellation, streaming — are where you earn your salary. Pair it with async/await, always use AbortController, and wrap it once. You’ll save yourself a lot of late-night debugging.
