Fetch API and Ajax: Building Dynamic Web Applications
Learn how to build dynamic, interactive web application features using the modern Fetch API and Ajax. This beginner-friendly guide covers basic fetch requests through advanced patterns like request cancellation, retry logic, and parallel requests. Let's dive in.
Software Engineer
Schild Technologies
Fetch API and Ajax: Building Dynamic Web Applications
If you've ever used a web application that updates content without refreshing the entire page—like Gmail loading new emails or Twitter showing new tweets—you've experienced Ajax in action. Ajax (Asynchronous JavaScript and XML) revolutionized web development by enabling dynamic, responsive user experiences.
What is Ajax?
Ajax is a set of techniques that combine several web technologies to create interactive applications. Ajax allows you to:
- Send requests to a server in the background
- Receive data from the server
- Update parts of a web page without reloading the entire page
Traditionally, XMLHttpRequest (XHR) was the API that made this possible. However, modern JavaScript now provides the Fetch API—a cleaner, promise-based approach to making HTTP requests.
Imagine web pages as letters you mail back and forth—each interaction requires sending a complete new letter (full page reload). Ajax is more like a phone conversation—you can exchange information continuously without hanging up and redialing.
Why use Ajax?
Before diving into the technical details, let's understand why Ajax matters:
Traditional approach
- User clicks a button
- Browser sends request to server
- Server processes request
- Server sends back entire HTML page
- Browser reloads and displays new page
Ajax approach
- User clicks a button
- JavaScript sends request to server in background
- Server processes request
- Server sends back only the needed data (usually JSON)
- JavaScript updates just the relevant part of the page
Benefits:
- Better user experience: No jarring page reloads
- Reduced bandwidth: Only transfer necessary data
- Faster interactions: Update specific page sections
- Desktop-like feel: More responsive and interactive
Understanding the Fetch API
The Fetch API is the modern standard for making HTTP requests in JavaScript. It's promise-based, making it cleaner and more intuitive than the older XMLHttpRequest approach. The API's main function is fetch(), which relies on promises.
Promises
When fetch() is called, it doesn't wait for the server to respond. Instead, it immediately returns a promise: a placeholder for data that doesn't exist yet but will arrive in the future. This allows your code to continue executing without blocking-the situation where execution stops and waits.
A promise exists in one of three states:
- Pending: Operation in progress (request sent, waiting for response)
- Fulfilled: Operation succeeded (response received)
- Rejected: Operation failed (network error, server unreachable, timeout)
Basic fetch request
You interact with promises using .then() to handle success and .catch() to handle errors. Here's how this works with a GET request:
// Make a GET request
fetch('/api/users') // (1) Returns a promise immediately (pending state)
.then(response => {
// (3) This callback runs when the promise fulfills (response arrives)
if (!response.ok) { // (4)
throw new Error(`HTTP error! status: ${response.status}`);
}
// Parse the response body as JSON
// (5) response.json() also returns a promise
return response.json();
})
.then(data => { // (6)
// This runs when the json() promise fulfills (parsing complete)
console.log('Users:', data);
})
.catch(error => { // (7)
// This runs if ANY promise in the chain rejects
console.error('Error:', error);
});
// (2) This line executes immediately - doesn't wait for the request
console.log('Request sent!');
Let's break this down:
- fetch('/api/users') immediately returns a promise and sends the HTTP request
- JavaScript continues executing (logs "Request sent!" without waiting)
- When the server responds, the first .then() callback executes with the response object
- response.ok checks if the HTTP status is 200-299 (success range)
- response.json() returns another promise that parses the response body as JSON
- The second .then() receives the parsed JavaScript object
- If any step fails (network error, non-ok status, invalid JSON), .catch() handles it
In normal contexts, users can continue interacting with page elements while the chain completes.
Configuring fetch requests
Fetch accepts a second options parameter to configure requests. The type of request, the data format, and the payload are specified in the second parameter.
// GET request (default)
fetch('/api/users');
// POST request
fetch('/api/users', {
method: 'POST', // HTTP method
headers: { // Request headers
'Content-Type': 'application/json', // Tell server we're sending JSON
},
body: JSON.stringify({ // Data to send (must be string for JSON)
name: 'Jane Smith',
email: 'jane@example.com'
})
});
// PUT request
fetch('/api/users/5', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Jane Smith Updated'
})
});
// DELETE request
fetch('/api/users/5', {
method: 'DELETE'
});
Using async/await
Promise chains with .then() work well for simple requests, but they become difficult to read with complex logic—multiple sequential requests, conditional branching, or different error handling at various stages. Modern JavaScript provides async/await syntax that makes asynchronous code read like synchronous code while maintaining non-blocking behavior.
async function getUsers() { // 'async' makes this an async function
try {
// 'await' pauses here until fetch completes
const response = await fetch('/api/users'); // (1)
if (!response.ok) { // (2)
throw new Error(`HTTP error! status: ${response.status}`);
}
// 'await' pauses here until json() completes
const data = await response.json(); // (3)
console.log('Users:', data); // (4)
return data; // This returns a promise that resolves to 'data'
} catch (error) {
console.error('Error:', error);
}
}
// Call the function
getUsers();
Again, what's happening?
- Function waits at await fetch() until the HTTP request completes
- Once the response arrives, it continues to the next line
- Waits again at await response.json() until JSON parsing completes
- Then logs the data and returns it
This is functionally identical to the first example.
Response object
Every fetch request returns a response object containing metadata about the server's reply. These properties enable proper handling of different scenarios—errors, redirects, and content types—before parsing the response body.
const response = await fetch('/api/users');
console.log(response.status); // 200, 404, 500, etc.
console.log(response.statusText); // "OK", "Not Found", etc.
console.log(response.ok); // true if status 200-299
console.log(response.headers); // Headers object
console.log(response.url); // Final URL (after redirects)
// Check specific header
console.log(response.headers.get('Content-Type'));
- response.ok indicates whether the request succeeded (status 200-299). The status property provides the specific HTTP status code.
- response.statusText is the technical meaning of the status code. For example, "Not Found," "Authentication Required," "Server Error," and so on.
- response.headers contains metadata sent by the server. Most crucial, Content-Type tells you how to parse the response—as JSON, plain text, binary data, etc. Other useful headers include Content-Disposition for filenames in downloads and Cache-Control for caching behavior.
- response.url is the destination URL. If the endpoint redirects to another URL, response.url would indicate the destination.
Example
Let's walk through a practical example-submitting a user registration form-demonstrating how the response object, async/await, and proper error handling work together to create a smooth user experience:
HTML:
<form id="userForm">
<input type="text" id="name" placeholder="Name" required>
<input type="email" id="email" placeholder="Email" required>
<input type="number" id="age" placeholder="Age">
<button type="submit">Create User</button>
</form>
<div id="message"></div>
JavaScript:
const form = document.getElementById('userForm');
const messageDiv = document.getElementById('message');
form.addEventListener('submit', async function(event) { // (1)
// Prevent default form submission
event.preventDefault(); // (2)
// Get form data
const userData = { // (3)
name: document.getElementById('name').value,
email: document.getElementById('email').value,
age: parseInt(document.getElementById('age').value) || null
};
try {
const response = await fetch('/api/users/create/', { // (4)
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
const data = await response.json(); // (5)
// (6)
if (response.status === 201) {
messageDiv.innerHTML = '<p style="color: green;">User created successfully!</p>';
form.reset();
} else if (response.status === 400) {
messageDiv.innerHTML = `<p style="color: red;">Error: ${data.error}</p>`;
} else {
messageDiv.innerHTML = '<p style="color: red;">An error occurred.</p>';
}
// (7)
} catch (error) {
console.error('Error:', error);
messageDiv.innerHTML = '<p style="color: red;">Network error occurred.</p>';
}
});
- An async function is attached to the form's submit event. This function runs whenever the user clicks "Create User" or presses Enter.
- event.preventDefault() stops the browser from performing its traditional form submission (which would reload the page). Instead, everything is handled in JavaScript.
- The form inputs are extracted and organized into a JavaScript object. The age field uses parseInt() with a fallback to null for empty values.
- fetch() is called with method POST, the Content-Type header is set to indicate the payload is JSON, and the JavaScript object is converted to a JSON string with JSON.stringify().
- response.json() is called to parse the server's JSON response. This works regardless of success or failure—the server always returns JSON with either user data or error details.
- Users receive feedback depending on the response status:
- Status 201: Show success message and clear the form
- Status 400: Display the server's validation error message
- Other statuses: Show a generic error message
- Error handling: The try/catch block catches network failures (server down, no internet) or JSON parsing errors, displaying a user-friendly message instead of breaking the page.
The form is submitted and the user receives immediate feedback without a full page refresh.
Best practices
1. Aborting requests
In live search scenarios, where typing a word or name triggers a new search with each successive character, the result is race conditions, where results might arrive out of order. Stale results might overwrite fresh results. Be sure to cancel pending requests using the Fetch API's AbortController:
let controller = new AbortController();
async function searchUsers(query) {
// Abort previous request if still pending
controller.abort();
controller = new AbortController();
try {
const response = await fetch(`/api/users/search/?q=${query}`, {
signal: controller.signal
});
const data = await response.json();
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else {
console.error('Error:', error);
}
}
}
2. Request timeout handling
Network requests can hang indefinitely if a server is overloaded or unresponsive. Users might wait minutes staring at a loading spinner, unsure if the request is still processing or has silently failed. Fetch doesn't have built-in timeout support, but you can implement it:
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
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');
}
throw error;
}
}
// Usage
try {
const response = await fetchWithTimeout('/api/slow-endpoint', {}, 5000);
const data = await response.json();
} catch (error) {
console.error('Error:', error.message);
}
3. Progress tracking for uploads
When users upload large files—videos, high-resolution images, datasets—it's good to show progress. Without it, a 500MB upload often looks identical to a frozen browser or failed request. Users won't know whether to wait, refresh, or give up. The Streams API is useful:
async function uploadWithProgress(file, onProgress) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!response.body) {
throw new Error('ReadableStream not supported');
}
const reader = response.body.getReader();
const contentLength = +response.headers.get('Content-Length');
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedLength += value.length;
const progress = (receivedLength / contentLength) * 100;
onProgress(progress);
}
return response;
}
// Usage
const fileInput = document.getElementById('fileInput');
uploadWithProgress(fileInput.files[0], (progress) => {
console.log(`Upload progress: ${progress.toFixed(2)}%`);
});
4. Retry logic
Servers experience momentary overload, load balancers restart, and network connections briefly drop. These transient failures resolve within seconds, but without retry logic, applications fail immediately—forcing users to manually refresh and hope it works. Although rare, consider implementing automatic retries for failed requests:
async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) {
return response;
}
// Don't retry 4xx errors (client errors)
if (response.status >= 400 && response.status < 500) {
return response;
}
} catch (error) {
// Network error
if (i === retries - 1) throw error;
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Usage
try {
const response = await fetchWithRetry('/api/unreliable-endpoint', {}, 3, 2000);
const data = await response.json();
} catch (error) {
console.error('Failed after 3 retries:', error);
}
Common pitfalls
Not checking response.ok before parsing
Fetch doesn't reject on HTTP error statuses (404, 500, etc.):
// Bad - will fail if response is error
const data = await fetch('/api/users').then(r => r.json());
// Good - check status first
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
JSON parsing errors
JSON parsing can fail with invalid data:
try {
const response = await fetch('/api/data');
const data = await response.json();
} catch (error) {
if (error instanceof SyntaxError) {
console.error('Invalid JSON:', error);
} else {
console.error('Fetch error:', error);
}
}
Sending cookies
By default, fetch() doesn't send cookies to different origins:
// Send cookies with request
const response = await fetch('/api/users', {
credentials: 'include' // or 'same-origin' for same domain only
});
Content-Type specification
Set correct Content-Type for POST/PUT requests. Without using FormData, Content-Type must be specified:
// Sending JSON
fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
// Sending FormData (Content-Type set automatically)
const formData = new FormData();
formData.append('name', 'John');
fetch('/api/users', {
method: 'POST',
body: formData // Don't set Content-Type header!
});
Fetch API vs XMLHttpRequest
While this article focuses on Fetch, here's a quick comparison:
| Feature | Fetch API | XMLHttpRequest |
|---|---|---|
| Syntax | Promise-based | Callback-based |
| Error handling | Clean with try/catch | Multiple event handlers |
| Request abort | AbortController | xhr.abort() |
| Upload progress | Possible but complex | Built-in with xhr.upload |
| Browser support | Modern browsers | All browsers |
| Code readability | Cleaner, more modern | More verbose |
Fetch is recommended for new projects, but XHR is still useful for:
- Legacy browser support (IE11)
- Simpler upload progress tracking
- Legacy codebases
Conclusion
The Fetch API and Ajax techniques are fundamental for building modern, interactive web applications. By allowing asynchronous communication with servers, they enable the smooth, desktop-like experience users expect. Start experimenting with the Fetch API in your projects, and you'll quickly discover why it has become the standard for asynchronous requests in modern web development.