An async function always returns a Promise. Inside it, await pauses only that function until the awaited value settles; it does not block the browser main thread. The fulfilled value becomes the result of the await expression, while a rejected Promise behaves like a thrown error and can be handled with try and catch.
The hard part is not the syntax. A reliable AJAX workflow must check HTTP status, decide which requests truly depend on earlier results, connect concurrent promises immediately, cancel obsolete work, and preserve enough error context for the caller to choose a retry, fallback, or user message.
fetch() returns a Promise that fulfills with a Response after response headers arrive. A 404 or 500 is still a fulfilled Fetch promise, so await fetch(url) does not automatically throw for an HTTP error. Check response.ok or response.status before parsing the body.
Response body readers such as json() are asynchronous too. Parse only after checking the status, and remember that a body can be empty or invalid even when its Content-Type claims JSON. Let low-level request helpers throw a useful error; let the UI boundary decide what the learner or user should see.
// async function - always returns a Promise
async function getUser(id) {
// try-catch replaces .catch() for error handling
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json(); // await the body parsing too
return user;
} catch (error) {
console.error('getUser failed:', error.message);
throw error; // re-throw so callers can handle it
} finally {
// finally always runs - good for cleanup
console.log('getUser request complete');
}
}
// Call the async function
getUser(1)
.then(user => console.log('User:', user.name))
.catch(err => console.error('Caught:', err.message));
Use sequential awaits when request B needs data produced by request A. When requests are independent, start them together and await Promise.all(). Sequential independent fetches add their waiting times; concurrent fetches overlap them.
Promise.all() rejects when any input rejects, but it connects every input to the combined chain immediately. Promise.allSettled() waits for every input and returns a status for each, which is useful when a dashboard can show partial results. Neither method cancels remaining network requests automatically.
// ---- SEQUENTIAL - each request waits for the previous ----
// Total time = sum of all request times (slow!)
async function loadSequential() {
const user = await fetch('/api/users/1').then(r => r.json());
const posts = await fetch('/api/posts?userId=1').then(r => r.json());
const profile = await fetch('/api/profile/1').then(r => r.json());
// Runs in series: user -> posts -> profile
return { user, posts, profile };
}
// ---- PARALLEL with Promise.all - all requests fire simultaneously ----
// Total time = longest single request (fast!)
async function loadParallel() {
const [user, posts, profile] = await Promise.all([
fetch('/api/users/1').then(r => r.json()),
fetch('/api/posts?userId=1').then(r => r.json()),
fetch('/api/profile/1').then(r => r.json())
]);
// All three requests run concurrently
return { user, posts, profile };
}
// ---- Promise.allSettled - get results even if some fail ----
async function loadWithFallback() {
const results = await Promise.allSettled([
fetch('/api/users/1').then(r => r.json()),
fetch('/api/posts?userId=1').then(r => r.json()),
fetch('/api/missing-endpoint').then(r => r.json()) // this will fail
]);
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`Request ${i} succeeded:`, result.value);
} else {
console.warn(`Request ${i} failed:`, result.reason.message);
}
});
}
Search boxes and route changes can make an earlier request obsolete. AbortController provides a signal that Fetch observes. Abort the previous controller before starting a replacement, and treat AbortError as expected control flow rather than showing a failure banner.
Cancellation does not guarantee the server stopped processing after it received the request. State-changing endpoints still need server-side authorization, validation, transactions, and idempotency where retries are possible.
let activeController;
async function searchProducts(query) {
activeController?.abort();
activeController = new AbortController();
try {
const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`, {
signal: activeController.signal,
});
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
return await response.json();
} catch (error) {
if (error.name === "AbortError") return [];
throw error;
}
}
Catch an error where you can add context, recover, or translate it into a user-facing state. A helper that logs and silently returns undefined forces every caller to fail later with a less useful message. Either return a valid fallback with a documented meaning or rethrow after adding context.
Use finally for cleanup that must run on success and failure, such as clearing a loading indicator. Do not put the success-only render path in finally. For retries, limit attempts, delay between them, and retry only transient operations that are safe to repeat.
// Async IIFE - run async code at the top level (pre-ES2022)
(async () => {
try {
const res = await fetch('/api/config');
const config = await res.json();
console.log('App config loaded:', config);
initApp(config);
} catch (err) {
console.error('Failed to load config:', err);
}
})();
// Top-level await (ES2022, requires type="module")
// <script type="module">
// const res = await fetch('/api/config');
// const config = await res.json();
// initApp(config);
// </script>
// Error propagation through async call chains
async function step1() {
const data = await fetch('/api/step1').then(r => r.json());
return data.value;
}
async function step2(value) {
const data = await fetch(`/api/step2?v=${value}`).then(r => r.json());
return data.result;
}
async function runPipeline() {
try {
const v1 = await step1();
const v2 = await step2(v1);
console.log('Pipeline result:', v2);
} catch (err) {
// Catches errors from step1 OR step2
console.error('Pipeline failed:', err.message);
}
}
// Pattern 1: Loading state management
async function loadAndRender(url, containerId) {
const container = document.getElementById(containerId);
container.innerHTML = '<div class="spinner">Loading...</div>';
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
container.innerHTML = renderData(data);
} catch (err) {
container.innerHTML = `<p class="error">${err.message}</p>`;
}
}
// Pattern 2: Polling - check for updates every N seconds
async function pollForStatus(jobId, intervalMs = 2000) {
while (true) {
const res = await fetch(`/api/jobs/${jobId}`);
const job = await res.json();
if (job.status === 'completed') {
console.log('Job done:', job.result);
break;
} else if (job.status === 'failed') {
throw new Error('Job failed: ' + job.error);
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
// Pattern 3: Race - use whichever request finishes first
async function fetchWithFallback(primaryUrl, fallbackUrl) {
try {
return await Promise.race([
fetch(primaryUrl).then(r => r.json()),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
]);
} catch {
console.warn('Primary failed, using fallback');
return fetch(fallbackUrl).then(r => r.json());
}
}
Try this next
0 of 3 completed
No. It suspends the surrounding async function while other tasks can continue; CPU-heavy synchronous work still blocks.
It observes promises that should already represent started work. JavaScript remains single-threaded for normal code while network operations can overlap.
Use it when every outcome matters and partial success is useful, such as independent dashboard panels.
Explore 500+ free tutorials across 20+ languages and frameworks.