Lesson 4.4: Fetch API
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Use the Fetch API to make HTTP requests
- Handle responses and parse different data formats
- Configure fetch with headers, body, and options
- Handle errors properly when using fetch
- Implement timeout and abort functionality
- Type fetch requests and responses with TypeScript
Introduction
The Fetch API is the modern way to make HTTP requests in JavaScript. It is built into browsers and Node.js (v18+), uses Promises, and provides a cleaner interface than the older XMLHttpRequest.
// The simplest fetch call
const response = await fetch('https://api.example.com/data');
const data = await response.json();
Basic Fetch Usage
Making a GET Request
// Simple GET request
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const post = await response.json();
console.log(post.title);
The Response Object
Fetch returns a Response object with several useful properties:
const response = await fetch('https://api.example.com/users');
// Status information
console.log(response.status); // 200
console.log(response.statusText); // "OK"
console.log(response.ok); // true (status 200-299)
// Headers
console.log(response.headers.get('Content-Type'));
// URL (after any redirects)
console.log(response.url);
// Was the request redirected?
console.log(response.redirected);
// Response type
console.log(response.type); // "basic", "cors", etc.
Reading Response Body
The response body can only be read once. Choose the appropriate method:
// Parse as JSON
const json = await response.json();
// Parse as text
const text = await response.text();
// Parse as binary (Blob)
const blob = await response.blob();
// Parse as ArrayBuffer
const buffer = await response.arrayBuffer();
// Parse as FormData
const formData = await response.formData();
Important: You can only call one of these methods. The body stream is consumed after reading.
// WRONG - Second call will fail
const json = await response.json();
const text = await response.text(); // Error: Body already consumed
// If you need to read twice, clone first
const clone = response.clone();
const json = await response.json();
const text = await clone.text();
Configuring Fetch Requests
Fetch Options
const response = await fetch(url, {
method: 'POST', // HTTP method
headers: {
// Request headers
'Content-Type': 'application/json',
Authorization: 'Bearer token',
},
body: JSON.stringify(data), // Request body
mode: 'cors', // CORS mode
credentials: 'include', // Cookie handling
cache: 'no-cache', // Cache mode
redirect: 'follow', // Redirect handling
signal: abortController.signal, // For cancellation
});
Making POST Requests
interface User {
id: number;
name: string;
email: string;
}
interface CreateUserData {
name: string;
email: string;
}
async function createUser(userData: CreateUserData): Promise<User> {
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}`);
}
return response.json();
}
// Usage
const newUser = await createUser({
name: 'John Doe',
email: 'john@example.com',
});
Making PUT and PATCH Requests
// PUT - Replace entire resource
async function replaceUser(id: number, userData: CreateUserData): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
return response.json();
}
// PATCH - Partial update
async function updateUserEmail(id: number, email: string): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
return response.json();
}
Making DELETE Requests
async function deleteUser(id: number): Promise<void> {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete user: ${response.status}`);
}
}
Error Handling
Understanding Fetch Errors
Critical: Fetch only rejects on network errors, not HTTP errors like 404 or 500!
// This does NOT throw for 404 or 500
const response = await fetch('https://api.example.com/not-found');
// response.ok will be false, but no error thrown
// Network error (no internet, DNS failure) WILL throw
try {
await fetch('https://this-does-not-exist.example');
} catch (error) {
console.log('Network error:', error);
}
Proper Error Handling Pattern
async function fetchData<T>(url: string): Promise<T> {
let response: Response;
try {
response = await fetch(url);
} catch (error) {
// Network error
throw new Error(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
if (!response.ok) {
// HTTP error (4xx, 5xx)
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
return response.json();
}
Detailed Error Handling
class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public body?: unknown
) {
super(`API Error: ${status} ${statusText}`);
this.name = 'ApiError';
}
}
async function fetchWithErrorHandling<T>(url: string, options?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(url, options);
} catch (error) {
if (error instanceof TypeError) {
throw new Error('Network error: Please check your internet connection');
}
throw error;
}
if (!response.ok) {
let errorBody: unknown;
try {
errorBody = await response.json();
} catch {
// Response body is not JSON
}
throw new ApiError(response.status, response.statusText, errorBody);
}
return response.json();
}
// Usage with error handling
async function getUser(id: number): Promise<User> {
try {
return await fetchWithErrorHandling<User>(`https://api.example.com/users/${id}`);
} catch (error) {
if (error instanceof ApiError) {
if (error.status === 404) {
throw new Error(`User ${id} not found`);
}
if (error.status === 401) {
throw new Error('Please log in to view this user');
}
}
throw error;
}
}
Timeout and Cancellation
Using AbortController
Fetch supports cancellation through AbortController:
async function fetchWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Usage
try {
const data = await fetchWithTimeout('https://api.example.com/slow', 5000);
} catch (error) {
console.log(error.message); // "Request timed out after 5000ms"
}
Manual Cancellation
const controller = new AbortController();
// Start a long request
const fetchPromise = fetch('https://api.example.com/large-data', {
signal: controller.signal,
});
// Cancel it after user action
document.getElementById('cancelBtn')?.addEventListener('click', () => {
controller.abort();
});
try {
const response = await fetchPromise;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('Request was cancelled');
}
}
AbortSignal.timeout() (Modern Approach)
// Simpler timeout (Node.js 18+ and modern browsers)
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000), // 5 second timeout
});
TypeScript with Fetch
Typing Responses
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
// Option 1: Type assertion (simple but less safe)
const response = await fetch("https://api.example.com/posts/1");
const post = await response.json() as Post;
// Option 2: Generic function (better)
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json() as Promise<T>;
}
const post = await fetchJson<Post>("https://api.example.com/posts/1");
// Option 3: Runtime validation (safest - covered in Module 5)
import { z } from "zod";
const PostSchema = z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
body: z.string()
});
async function fetchPost(id: number): Promise<Post> {
const response = await fetch(`https://api.example.com/posts/${id}`);
const data = await response.json();
return PostSchema.parse(data); // Validates at runtime
}
Creating a Typed API Client
interface User {
id: number;
name: string;
email: string;
}
interface CreateUserData {
name: string;
email: string;
}
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
class ApiClient {
constructor(private baseUrl: string) {}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
// Users
async getUsers(): Promise<User[]> {
return this.request<User[]>('/users');
}
async getUser(id: number): Promise<User> {
return this.request<User>(`/users/${id}`);
}
async createUser(data: CreateUserData): Promise<User> {
return this.request<User>('/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
// Posts
async getPosts(): Promise<Post[]> {
return this.request<Post[]>('/posts');
}
async getUserPosts(userId: number): Promise<Post[]> {
return this.request<Post[]>(`/users/${userId}/posts`);
}
}
// Usage
const api = new ApiClient('https://jsonplaceholder.typicode.com');
const users = await api.getUsers();
const user = await api.getUser(1);
const posts = await api.getUserPosts(1);
Common Patterns
Retry Logic
async function fetchWithRetry<T>(
url: string,
options?: RequestInit,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.log(`Attempt ${attempt + 1} failed: ${lastError.message}`);
if (attempt < maxRetries - 1) {
// Wait before retrying (exponential backoff)
const delay = delayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw new Error(`Failed after ${maxRetries} attempts: ${lastError?.message}`);
}
Parallel Requests
interface User {
id: number;
name: string;
}
interface Post {
id: number;
title: string;
}
async function fetchUserData(userId: number): Promise<{ user: User; posts: Post[] }> {
// Fetch user and posts in parallel
const [userResponse, postsResponse] = await Promise.all([
fetch(`https://api.example.com/users/${userId}`),
fetch(`https://api.example.com/users/${userId}/posts`),
]);
if (!userResponse.ok || !postsResponse.ok) {
throw new Error('Failed to fetch user data');
}
const [user, posts] = await Promise.all([
userResponse.json() as Promise<User>,
postsResponse.json() as Promise<Post[]>,
]);
return { user, posts };
}
Request with Loading State
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
async function fetchWithState<T>(
url: string,
onStateChange: (state: FetchState<T>) => void
): Promise<T> {
onStateChange({ data: null, loading: true, error: null });
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = (await response.json()) as T;
onStateChange({ data, loading: false, error: null });
return data;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
onStateChange({ data: null, loading: false, error: err });
throw err;
}
}
// Usage
let state: FetchState<User[]> = { data: null, loading: false, error: null };
await fetchWithState<User[]>('https://api.example.com/users', (newState) => {
state = newState;
console.log('State updated:', state);
});
Exercises
Exercise 1: Basic Fetch
Fetch a list of posts from JSONPlaceholder and display the first 5 titles:
async function displayFirstFivePosts(): Promise<void> {}
displayFirstFivePosts();
Solution
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
async function displayFirstFivePosts(): Promise<void> {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const posts: Post[] = await response.json();
posts.slice(0, 5).forEach((post, index) => {
console.log(`${index + 1}. ${post.title}`);
});
}
displayFirstFivePosts();
Exercise 2: POST Request
Create a function to add a new comment to a post:
interface Comment {
id: number;
postId: number;
name: string;
email: string;
body: string;
}
interface CreateCommentData {
postId: number;
name: string;
email: string;
body: string;
}
async function createComment(data: CreateCommentData): Promise<Comment> {}
Solution
interface Comment {
id: number;
postId: number;
name: string;
email: string;
body: string;
}
interface CreateCommentData {
postId: number;
name: string;
email: string;
body: string;
}
async function createComment(data: CreateCommentData): Promise<Comment> {
const response = await fetch('https://jsonplaceholder.typicode.com/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to create comment: ${response.status}`);
}
return response.json();
}
// Test
const newComment = await createComment({
postId: 1,
name: 'My Comment',
email: 'test@example.com',
body: 'This is a great post!',
});
console.log('Created comment:', newComment);
Exercise 3: Error Handling
Create a function that fetches a user and handles different error scenarios:
async function getUserSafe(id: number): Promise<User | null> {
// Return user if found
// Return null if 404
// Throw error for other failures
}
Solution
interface User {
id: number;
name: string;
email: string;
}
async function getUserSafe(id: number): Promise<User | null> {
let response: Response;
try {
response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
} catch (error) {
throw new Error('Network error: Unable to connect to server');
}
if (response.status === 404) {
return null; // User not found
}
if (!response.ok) {
throw new Error(`Server error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Test
const user = await getUserSafe(1);
if (user) {
console.log('Found user:', user.name);
} else {
console.log('User not found');
}
const notFound = await getUserSafe(999);
console.log('User 999:', notFound); // null
Exercise 4: Fetch with Timeout
Implement a wrapper that adds timeout to any fetch request:
async function fetchWithTimeout(
url: string,
options?: RequestInit,
timeoutMs?: number
): Promise<Response> {
// Default timeout: 10 seconds
}
Solution
async function fetchWithTimeout(
url: string,
options?: RequestInit,
timeoutMs: number = 10000
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Test
try {
const response = await fetchWithTimeout('https://jsonplaceholder.typicode.com/posts', {}, 5000);
console.log('Status:', response.status);
} catch (error) {
console.error('Error:', error);
}
Key Takeaways
- Fetch returns a Promise that resolves to a Response object
- response.ok is true for status codes 200-299
- Fetch only rejects on network errors, not HTTP errors (4xx, 5xx)
- Response body can only be read once - clone if needed
- Use JSON.stringify() for request body, response.json() for parsing
- AbortController enables timeout and cancellation
- Always check response.ok before processing the response
- Create typed wrapper functions for better TypeScript integration
Resources
| Resource | Type | Level |
|---|---|---|
| MDN: Fetch API | Documentation | Beginner |
| MDN: Using Fetch | Tutorial | Beginner |
| MDN: AbortController | Documentation | Intermediate |
| JavaScript.info: Fetch | Tutorial | Beginner |
Next Lesson
While Fetch is powerful, some developers prefer Axios for its cleaner API and additional features. Let us explore Axios as an alternative.