How does JavaScript get data from a server? How do you load user profiles, submit forms, or fetch the latest posts from an API? The answer is the Fetch API, JavaScript's modern way to make network requests. According to the HTTP Archive's 2023 Web Almanac, the median web page makes over 70 HTTP requests, making efficient network handling essential for performance.
// This is how you fetch data in JavaScript
const response = await fetch('https://api.example.com/users/1')
const user = await response.json()
console.log(user.name) // "Alice"But to understand Fetch, you need to understand what's happening underneath: HTTP.
What you'll learn in this guide:
- How HTTP requests and responses work
- The five main HTTP methods (GET, POST, PUT, PATCH, DELETE)
- How to use the Fetch API to make requests
- Reading and parsing JSON responses
- The critical difference between network errors and HTTP errors
- Modern patterns with async/await
- How to cancel requests with AbortController
Prerequisite: This guide assumes you understand Promises and async/await. Fetch is Promise-based, so you'll need those concepts. If you're not comfortable with Promises yet, read that guide first!
What is HTTP?
HTTP (Hypertext Transfer Protocol) is the foundation of data communication on the web. Its current HTTP semantics are defined in RFC 9110, with caching specified in RFC 9111. Every time you load a webpage, submit a form, or fetch data with JavaScript, HTTP is the protocol making that exchange possible.
HTTP is not JavaScript. HTTP is a language-agnostic protocol. Python, Ruby, Go, Java, and every other language uses it too. We cover HTTP basics in this guide because understanding the protocol helps with using the Fetch API effectively. If you want to dive deeper into HTTP itself, check out the MDN resources below.
HTTP — MDN
Comprehensive guide to the HTTP protocol
An Overview of HTTP — MDN
How HTTP works under the hood
The Restaurant Analogy
HTTP follows a simple pattern called request-response. To understand it, imagine you're at a restaurant:
- You place an order (the request) — "I'd like the pasta, please"
- The waiter takes it to the kitchen (the network) — your order travels to where the food is prepared
- The kitchen prepares your meal (the server) — they process your request and make your food
- The waiter brings back your food (the response) — you receive what you asked for (hopefully!)
┌─────────────────────────────────────────────────────────────────────────┐
│ THE REQUEST-RESPONSE CYCLE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ YOU (Browser) KITCHEN (Server) │
│ ┌──────────┐ ┌──────────────┐ │
│ │ │ ──── "I'd like pasta" ────► │ │ │
│ │ :) │ (REQUEST) │ [chef] │ │
│ │ │ │ │ │
│ │ │ ◄──── Here you go! ──────── │ │ │
│ │ │ (RESPONSE) │ │ │
│ └──────────┘ └──────────────┘ │
│ │
│ The waiter (HTTP) is the protocol that makes this exchange work! │
│ │
└─────────────────────────────────────────────────────────────────────────┘Sometimes things go wrong:
- The kitchen is closed (server is down) — You can't even place an order
- They're out of pasta (404 Not Found) — The order was received, but they can't fulfill it
- Something's wrong in the kitchen (500 Server Error) — They tried but something broke
This request-response cycle is the core of how the web works. The Fetch API is JavaScript's modern way to participate in this cycle programmatically.
How Does HTTP Work?
Before diving into the Fetch API, let's understand the key concepts of HTTP itself.
The Request-Response Model
Every HTTP interaction follows a simple pattern:
Client Sends Request
Your browser (the client) sends an HTTP request to a server. The request includes what you want (the URL), how you want it (the method), and any additional info (headers, body).
Server Processes Request
The server receives the request, does whatever work is needed (database queries, calculations, etc.), and prepares a response.
Server Sends Response
The server sends back an HTTP response containing a status code (success/failure), headers (metadata), and usually a body (the actual data).
Client Handles Response
Your JavaScript code receives the response and does something with it: display data, show an error, redirect the user, etc.
HTTP Methods: What Do You Want to Do?
HTTP methods tell the server what action you want to perform. Think of them as verbs:
| Method | Purpose | Restaurant Analogy |
|---|---|---|
| GET | Retrieve data | "Can I see the menu?" |
| POST | Create new data | "I'd like to place an order" |
| PUT | Update/replace data | "Actually, change my order to pizza" |
| PATCH | Partially update data | "Add extra cheese to my order" |
| DELETE | Remove data | "Cancel my order" |
// GET - Retrieve a user
fetch('/api/users/123')
// POST - Create a new user
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' })
})
// PUT - Replace a user
fetch('/api/users/123', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice Updated' })
})
// PATCH - Partially update a user
fetch('/api/users/123', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Name' })
})
// DELETE - Remove a user
fetch('/api/users/123', {
method: 'DELETE'
})HTTP Status Codes: What Happened?
Status codes are three-digit numbers that tell you how the request went:
Quick Rule of Thumb:
- 2xx = "Here's what you asked for"
- 3xx = "Go look over there"
- 4xx = "You messed up"
- 5xx = "We messed up"
What is the Fetch API?
The Fetch API is JavaScript's modern interface for making HTTP requests. It provides a cleaner, Promise-based alternative to the older XMLHttpRequest, letting you send requests to servers and handle responses with simple, readable code. Every modern browser supports Fetch natively.
// Fetch in its simplest form
const response = await fetch('https://api.example.com/data')
const data = await response.json()
console.log(data)Before Fetch: The XMLHttpRequest Days
Before Fetch existed, developers used XMLHttpRequest (XHR), a verbose, callback-based API that powered "AJAX" requests. Libraries like jQuery became popular partly because they simplified this painful process. jQuery was revolutionary for JavaScript. For many years it was the go-to library that made DOM manipulation, animations, and AJAX requests much easier. It changed how developers wrote JavaScript and shaped the modern web.
// The old way: XMLHttpRequest (verbose and callback-based)
const xhr = new XMLHttpRequest()
xhr.open('GET', 'https://api.example.com/data')
xhr.onload = function() {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText)
console.log(data)
}
}
xhr.onerror = function() {
console.error('Request failed')
}
xhr.send()
// The modern way: Fetch (clean and Promise-based)
const response = await fetch('https://api.example.com/data')
const data = await response.json()
console.log(data)Unlike XMLHttpRequest, Fetch:
- Returns Promises instead of using callbacks
- Uses Request and Response objects for cleaner APIs
- Integrates naturally with async/await syntax
- Supports streaming responses out of the box
You no longer need jQuery for AJAX. The Fetch API is built into every modern browser, making libraries unnecessary for basic HTTP requests.
How to Use the Fetch API
Now that you understand what Fetch is and how it compares to older approaches, let's dive into the details of using it effectively.
How to Make a Fetch Request
Call fetch() with a URL
The fetch() function takes a URL and returns a Promise that resolves to a Response object. By default, it makes a GET request.
Check if the response was successful
Always verify response.ok before processing. Fetch doesn't throw errors for HTTP status codes like 404 or 500.
Parse the response body
Use response.json() for JSON data or response.text() for plain text. These methods return another Promise.
Handle errors properly
Wrap everything in try/catch to handle both network failures and HTTP error responses.
Here's what this looks like in code. By default, fetch() uses the GET method, so you don't need to specify it. There are two ways to write this:
// Basic fetch - returns a Promise
fetch('https://api.example.com/users')
.then(response => {
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
return response.json()
})
.then(data => console.log(data))
.catch(error => console.error('Error:', error))Let's break this down step by step:
// Step 1: fetch() returns a Promise that resolves to a Response object
const responsePromise = fetch('https://api.example.com/users')
// Step 2: When the response arrives, we get a Response object
responsePromise.then(response => {
console.log(response.status) // 200
console.log(response.ok) // true
console.log(response.headers) // Headers object
// Step 3: The body is a stream, we need to parse it
// .json() returns ANOTHER Promise
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
return response.json()
})
.then(data => {
// Step 4: Now we have the actual data
console.log(data) // { users: [...] }
})// Using async/await - cleaner syntax
async function getUsers() {
try {
const response = await fetch('https://api.example.com/users')
const data = await response.json()
console.log(data)
} catch (error) {
console.error('Error:', error)
}
}Let's break this down step by step:
async function getUsers() {
// Step 1: await pauses until the Response arrives
const response = await fetch('https://api.example.com/users')
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
console.log(response.status) // 200
console.log(response.ok) // true
console.log(response.headers) // Headers object
// Step 2: await again to read and parse the body
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const data = await response.json()
// Step 3: Now we have the actual data
console.log(data) // { users: [...] }
}Which should you use? async/await is generally preferred for its cleaner, more readable syntax. Use .then() chains when you need to integrate with older codebases or when you specifically want to avoid async functions.
Understanding the Response Object
When fetch() resolves, you get a Response object. This object contains everything about the server's reply: status codes, headers, and methods to read the body:
const response = await fetch('https://api.example.com/users/1')
// Status information
response.status // 200, 404, 500, etc.
response.statusText // "OK", "Not Found", "Internal Server Error"
response.ok // true if status is 200-299
// Response metadata
response.headers // Headers object
response.url // Final URL (after redirects)
response.type // "basic", "cors", etc.
response.redirected // true if response came from a redirect
// Body methods (each returns a Promise)
response.json() // Parse body as JSON
response.text() // Parse body as plain text
response.blob() // Parse body as binary Blob
response.formData() // Parse body as FormData
response.arrayBuffer() // Parse body as ArrayBuffer
response.bytes() // Parse body as Uint8ArrayImportant: The body can only be read once! If you call response.json(), you can't call response.text() afterward. If you need to read it multiple times, clone the response first with response.clone().
Reading JSON Data
Most modern APIs return data in JSON format. The Response object has a built-in .json() method that parses the body and returns a JavaScript object:
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`)
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const user = await response.json()
console.log(user.name) // "Alice"
console.log(user.email) // "alice@example.com"
return user
}Sending Data with POST
So far we've only retrieved data. But what about sending data, like creating a user account or submitting a form?
That's where POST comes in. It's the HTTP method that tells the server "I'm sending you data to create something new." To make a POST request, you need to specify the method, set a Content-Type header, and include your data in the body:
async function createUser(userData) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
})
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const newUser = await response.json()
return newUser
}
// Usage
const user = await createUser({
name: 'Bob',
email: 'bob@example.com'
})
console.log(user.id) // New user's ID from serverSetting Headers
HTTP headers are metadata you send with your request: things like authentication tokens, content types, and caching instructions. You pass them as an object in the headers option:
const response = await fetch('https://api.example.com/data', {
method: 'GET',
headers: {
// Tell server what format we want
'Accept': 'application/json',
// Authentication token
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
// Custom header
'X-Custom-Header': 'custom-value'
}
})Common headers you'll use:
| Header | Purpose |
|---|---|
Content-Type | Format of data you're sending (e.g., application/json) |
Accept | Format of data you want back |
Authorization | Authentication credentials |
Cache-Control | Caching instructions |
Building URLs with Query Parameters
When fetching data, you often need to include query parameters (e.g., /api/search?q=javascript&page=1). Use the URL and URLSearchParams APIs to build URLs safely:
// Building a URL with query parameters
const url = new URL('https://api.example.com/search')
url.searchParams.set('q', 'javascript')
url.searchParams.set('page', '1')
url.searchParams.set('limit', '10')
console.log(url.toString())
// "https://api.example.com/search?q=javascript&page=1&limit=10"
// Use with fetch
const response = await fetch(url)You can also use URLSearchParams directly:
const params = new URLSearchParams({
q: 'javascript',
page: '1'
})
// Append to a URL string
const response = await fetch(`/api/search?${params}`)Why use URL/URLSearchParams instead of string concatenation? These APIs automatically handle URL encoding for special characters. If a user searches for "C++ tutorial", it becomes q=C%2B%2B+tutorial. Something you'd have to handle manually with string concatenation.
The #1 Fetch Mistake
Here's a mistake almost every developer makes when learning fetch:
"I wrapped my fetch in try/catch, so I'm handling all errors... right?"
Wrong. The problem? fetch() only throws an error when the network fails, not when the server returns a 404 or 500. A "Page Not Found" response is still a successful network request from fetch's perspective!
Two Types of "Errors"
When working with fetch(), there are two completely different types of failures:
┌─────────────────────────────────────────────────────────────────────────┐
│ TWO TYPES OF FAILURES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. NETWORK ERRORS 2. HTTP ERROR RESPONSES │
│ ──────────────────── ─────────────────────── │
│ │
│ • Server unreachable • Server responded with error │
│ • DNS lookup failed • 404 Not Found │
│ • No internet connection • 500 Internal Server Error │
│ • Request timed out • 401 Unauthorized │
│ • CORS blocked • 403 Forbidden │
│ │
│ Promise REJECTS ❌ Promise RESOLVES ✓ │
│ Goes to .catch() response.ok is false │
│ │
└─────────────────────────────────────────────────────────────────────────┘The Trap: fetch() only rejects its Promise for network errors. An HTTP 404 or 500 response is still a "successful" fetch. The network request completed! You must check response.ok to detect HTTP errors.
The Mistake: Only Catching Network Errors
This code looks fine, but it has a subtle bug. HTTP errors like 404 or 500 slip right through the catch block:
// ❌ WRONG - This misses HTTP errors!
try {
const response = await fetch('/api/users/999')
const data = await response.json()
console.log(data) // Might be an error object!
} catch (error) {
// Only catches NETWORK errors
// A 404 response WON'T end up here!
console.error('Error:', error)
}The Fix: Always Check response.ok
The solution is simple: check response.ok before assuming success. This property is true for status codes 200-299 and false for everything else:
// ✓ CORRECT - Check response.ok
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`)
// Check if the HTTP response was successful
if (!response.ok) {
// HTTP error (4xx, 5xx) - throw to catch block
throw new Error(`HTTP error! Status: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
// Now this catches BOTH network errors AND HTTP errors
console.error('Fetch failed:', error.message)
throw error
}
}Building a Reusable Fetch Helper
Here's a pattern you can use in real projects: a wrapper function that handles the response.ok check for you:
async function fetchJSON(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
})
// Handle HTTP errors
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
error.response = response
throw error
}
// Handle empty responses (like 204 No Content)
if (response.status === 204) {
return null
}
return response.json()
}
// Usage
try {
const user = await fetchJSON('/api/users/1')
console.log(user)
} catch (error) {
if (error.status === 404) {
console.log('User not found')
} else if (error.status >= 500) {
console.log('Server error, try again later')
} else {
console.log('Request failed:', error.message)
}
}How to Use async/await with Fetch
The examples above use .then() chains, but modern JavaScript has a cleaner syntax: async/await. If you're not familiar with it, check out our async/await concept first. It'll make your fetch code much easier to read.
Basic async/await Pattern
async function loadUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`Failed to load user: ${response.status}`)
}
const user = await response.json()
return user
} catch (error) {
console.error('Error loading profile:', error)
return null
}
}
// Usage
const user = await loadUserProfile(123)
if (user) {
console.log(`Welcome, ${user.name}!`)
}Parallel Requests
Need to fetch multiple resources? Don't await them one by one:
// ❌ SLOW - Sequential requests (one after another)
async function loadDashboardSlow() {
const user = await fetch('/api/user').then(r => r.json())
const posts = await fetch('/api/posts').then(r => r.json())
const notifications = await fetch('/api/notifications').then(r => r.json())
// Total time: user + posts + notifications
return { user, posts, notifications }
}
// ✓ FAST - Parallel requests (all at once)
async function loadDashboardFast() {
const [user, posts, notifications] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/notifications').then(r => r.json())
])
// Total time: max(user, posts, notifications)
return { user, posts, notifications }
}Loading States Pattern
In real applications, you need to track loading and error states:
async function fetchWithState(url) {
const state = {
data: null,
loading: true,
error: null
}
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
state.data = await response.json()
} catch (error) {
state.error = error.message
} finally {
state.loading = false
}
return state
}
// Usage
const result = await fetchWithState('/api/users')
if (result.loading) {
console.log('Loading...')
} else if (result.error) {
console.log('Error:', result.error)
} else {
console.log('Data:', result.data)
}How to Cancel Requests
The AbortController API lets you cancel in-flight fetch requests. This is useful for:
- Timeouts — Cancel requests that take too long
- User navigation — Cancel pending requests when user leaves a page
- Search inputs — Cancel the previous search when user types new characters
- Component cleanup — Cancel requests when a React/Vue component unmounts
Without AbortController, abandoned requests continue running in the background, wasting bandwidth and potentially causing bugs when their responses arrive after you no longer need them.
How It Works
┌─────────────────────────────────────────────────────────────────────────┐
│ ABORTCONTROLLER FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Create controller 2. Pass signal to fetch │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ const controller = │ │ fetch(url, { │ │
│ │ new AbortController│ ───► │ signal: controller.signal │ │
│ └─────────────────────┘ │ }) │ │
│ └─────────────────────────────────┘ │
│ │
│ 3. Call abort() to cancel 4. Fetch rejects with AbortError │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ controller.abort() │ ───► │ catch (error) { │ │
│ └─────────────────────┘ │ error.name === 'AbortError' │ │
│ │ } │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘Basic AbortController Usage
// Create a controller
const controller = new AbortController()
// Pass its signal to fetch
fetch('/api/slow-endpoint', {
signal: controller.signal
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request was cancelled')
} else {
console.error('Request failed:', error)
}
})
// Cancel the request after 5 seconds
setTimeout(() => {
controller.abort()
}, 5000)Timeout Pattern
Create a reusable timeout wrapper:
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController()
// Set up timeout
const timeoutId = setTimeout(() => {
controller.abort()
}, timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal
})
clearTimeout(timeoutId)
return response
} catch (error) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeout}ms`)
}
throw error
}
}
// Usage
try {
const response = await fetchWithTimeout('/api/data', {}, 3000)
const data = await response.json()
} catch (error) {
console.error(error.message) // "Request timed out after 3000ms"
}Search Input Pattern
Cancel previous search when user types:
let currentController = null
async function searchUsers(query) {
// Cancel any in-flight request
if (currentController) {
currentController.abort()
}
// Create new controller for this request
currentController = new AbortController()
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
})
if (!response.ok) throw new Error('Search failed')
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
// Ignore - we cancelled this on purpose
return null
}
throw error
}
}
// As user types, only the last request matters
searchInput.addEventListener('input', async (e) => {
const results = await searchUsers(e.target.value)
if (results) {
displayResults(results)
}
})This example uses browser DOM APIs (addEventListener, searchInput). In Node.js or server-side contexts, you would trigger the search function differently, but the AbortController pattern remains the same.
Key Takeaways
The key things to remember:
-
HTTP is request-response — Client sends a request, server sends a response
-
HTTP methods are verbs — GET (read), POST (create), PUT (update), DELETE (remove)
-
Status codes tell you what happened — 2xx (success), 4xx (your fault), 5xx (server's fault)
-
Fetch returns a Promise — It resolves to a Response object, not directly to data
-
Response.json() is also a Promise — You need to await it too
-
Fetch only rejects on network errors — HTTP 404/500 still "succeeds" — check
response.ok! -
Always check response.ok — This is the most common fetch mistake
-
Use async/await — It's cleaner than Promise chains
-
Use Promise.all for parallel requests — Don't await sequentially when you don't have to
-
AbortController cancels requests — Useful for search inputs and cleanup
Test Your Knowledge
Frequently Asked Questions
Related Concepts
Promises
Fetch is Promise-based — you need to understand Promises first
Content coming soon.async/await
Modern syntax for working with Promises and fetch
Content coming soon.Event Loop
How JavaScript handles async operations like fetch
DOM
Often you'll fetch data and update the DOM with it
Reference
Fetch API — MDN
Official MDN documentation for the Fetch API
Using Fetch — MDN
Comprehensive guide to using the Fetch API
Response — MDN
Documentation for the Response object
AbortController — MDN
Documentation for cancelling fetch requests
Articles
How to Use Fetch with async/await
Breaks down fetch into 5 simple recipes you can copy-paste. Great reference when you forget the exact syntax for POST requests or headers.
JavaScript Fetch API Ultimate Guide
Kyle Cook's written version of his popular YouTube tutorials. Covers GET, POST, error handling, and AbortController with the same clear teaching style.
Fetch API Error Handling
The article that explains why fetch doesn't reject on 404/500. Short read that saves you hours of debugging the "#1 fetch mistake."
Videos
JavaScript Fetch API
Brad builds a simple app while explaining fetch concepts. Good if you learn better by watching someone code than reading docs.
Learn Fetch API in 6 Minutes
The fastest way to learn fetch if you're short on time. Kyle covers the essentials without any filler.
Async JS Crash Course - Callbacks, Promises, Async/Await
Covers callbacks, Promises, and async/await before getting to fetch. Watch this if you want the full async picture, not just fetch.