LLevelUp
0
← Back to topic

Fetch API & Async/Await

Modern web applications rarely live in isolation — they fetch data from APIs, send form submissions, and stream responses from servers. The Fetch API is the browser’s built-in way to make HTTP requests from JavaScript. Combined with async/await, it reads almost like synchronous code while remaining fully non-blocking.

Basic GET Request

// Promise chain style
fetch('https://api.example.com/users')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Failed:', error))

// async/await style — functionally identical, much more readable
async function getUsers() {
  const response = await fetch('https://api.example.com/users')
  const data = await response.json()
  console.log(data)
}

Why fetch() Doesn’t Throw on HTTP Errors

This is the most common mistake when learning the Fetch API:

// ❌ This only catches network failures (no internet, DNS error)
// A 404 or 500 response does NOT throw — fetch() resolves successfully
async function badFetch() {
  try {
    const res = await fetch('/api/user/999')
    const data = await res.json()  // ← you'll get an error body, not a throw
    return data
  } catch (e) {
    console.error('Only fires on network failure, not 404/500')
  }
}

// ✅ Always check response.ok (true for 200–299 status codes)
async function goodFetch() {
  try {
    const res = await fetch('/api/user/999')
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}: ${res.statusText}`)
    }
    const data = await res.json()
    return data
  } catch (error) {
    console.error('Request failed:', error)
    throw error  // re-throw so callers can handle it
  }
}

IMPORTANT

fetch() only rejects (throws) for network-level failures: no internet connection, DNS resolution failure, or a request that was blocked. A server returning 404 Not Found or 500 Internal Server Error is still a successful HTTP response — always check response.ok.

Sending Data (POST Request)

interface CreateUserPayload {
  name: string
  email: string
}

async function createUser(payload: CreateUserPayload) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getAuthToken()}`,
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) {
    const errorBody = await response.json().catch(() => null)
    throw new Error(errorBody?.message ?? `HTTP ${response.status}`)
  }

  return response.json() as Promise<{ id: string; name: string; email: string }>
}

// Usage
const user = await createUser({ name: 'Alice', email: '[email protected]' })
console.log('Created user:', user.id)

CORS: Cross-Origin Resource Sharing

CORS is a browser security mechanism that blocks JavaScript from making requests to a different origin (scheme + host + port) than the current page. The browser sends a preflight OPTIONS request to ask the server if the cross-origin request is allowed.

Page at:    https://myapp.com
Fetch to:   https://api.other.com/data  ← cross-origin → browser checks CORS headers

The server must respond with the right CORS headers:

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization

NOTE

CORS is enforced by the browser, not the server. A CORS block does not mean the server rejected the request — the server likely responded fine, but the browser hid the response from JavaScript. This is why curl works when fetch does not.

Cancelling a Fetch with AbortController

Long-running requests (especially in React or similar frameworks) should be cancellable to prevent state updates after a component unmounts:

async function searchUsers(query: string) {
  const controller = new AbortController()

  // Cancel after 5 seconds
  const timeoutId = setTimeout(() => controller.abort(), 5000)

  try {
    const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
    clearTimeout(timeoutId)

    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return await res.json()
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      console.log('Request cancelled')
      return null
    }
    throw error
  }
}

Practical Example: Fetching from a Public API

interface GitHubUser {
  login: string
  name: string
  public_repos: number
  followers: number
}

async function fetchGitHubProfile(username: string): Promise<GitHubUser> {
  const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, {
    headers: {
      Accept: 'application/vnd.github+json',
    },
  })

  if (res.status === 404) {
    throw new Error(`User "${username}" not found`)
  }
  if (!res.ok) {
    throw new Error(`GitHub API error: ${res.status}`)
  }

  return res.json()
}

// Usage
try {
  const user = await fetchGitHubProfile('torvalds')
  console.log(`${user.name} has ${user.public_repos} public repos`)
} catch (error) {
  console.error(error)
}

TIP

Use encodeURIComponent() whenever you insert user-provided strings into a URL — it escapes characters like &, ?, and spaces that would break the URL structure.

Further Learning

Search these terms to go deeper:

  • “MDN Fetch API using fetch” — the comprehensive reference including streaming responses and credentials
  • “CORS in depth web.dev” — how CORS headers work, simple vs preflighted requests
  • “async await error handling patterns” — strategies beyond basic try/catch for complex async flows
  • “Response.body ReadableStream” — how to handle streaming responses from APIs like Server-Sent Events
  • “SWR TanStack Query data fetching” — libraries that add caching, revalidation, and loading states on top of fetch