Introduction to JavaScript · Lesson 4 of 5
Async/Await & Fetch API
Async JavaScript — Promises, Async/Await, and Fetch
JavaScript runs on a single thread. If you block it with a slow operation (file read, HTTP request, database query), the whole page freezes. Async code lets long operations run without blocking.
The Problem: Callbacks
The original async pattern. Still seen in older code and Node.js core APIs.
// Callback hell — hard to read, hard to error-handle
fetchUser(userId, (err, user) => {
if (err) { handleError(err); return; }
fetchOrders(user.id, (err, orders) => {
if (err) { handleError(err); return; }
fetchProducts(orders[0].productId, (err, product) => {
if (err) { handleError(err); return; }
renderUI(user, orders, product);
});
});
});This "pyramid of doom" is why Promises were invented.
Promises
A Promise represents a value that will be available in the future (or an error).
// Creating a Promise
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) resolve("Data loaded!");
else reject(new Error("Something went wrong"));
}, 1000);
});
// Consuming a Promise
promise
.then(data => console.log(data)) // called on resolve
.catch(err => console.error(err)) // called on reject
.finally(() => console.log("Done")); // always calledPromise States
A Promise is always in one of three states:
- Pending — initial state, waiting
- Fulfilled — resolved successfully
- Rejected — failed with an error
Once fulfilled or rejected, it's settled and never changes state.
Chaining Promises
fetchUser(1)
.then(user => fetchOrders(user.id)) // return another Promise
.then(orders => fetchProduct(orders[0].productId))
.then(product => renderUI(product))
.catch(err => handleError(err)); // catches any error in the chainPromise.all, race, allSettled
// Promise.all — wait for ALL to resolve (fails fast if any reject)
const [user, products, settings] = await Promise.all([
fetchUser(1),
fetchProducts(),
fetchSettings()
]);
// Promise.allSettled — wait for all, get results regardless of success/failure
const results = await Promise.allSettled([
fetchUser(1),
fetchMightFail(),
fetchOther()
]);
results.forEach(result => {
if (result.status === "fulfilled") console.log(result.value);
if (result.status === "rejected") console.error(result.reason);
});
// Promise.race — first one wins (fulfilled or rejected)
const fastest = await Promise.race([
fetch("https://api1.example.com/data"),
fetch("https://api2.example.com/data")
]);
// Promise.any — first FULFILLED one wins (ignores rejections unless all fail)
const firstSuccess = await Promise.any([api1(), api2(), api3()]);async/await
async/await is syntactic sugar over Promises. It makes async code look synchronous.
// async function always returns a Promise
async function fetchUserData(userId) {
const user = await fetchUser(userId); // wait for Promise to resolve
const orders = await fetchOrders(user.id); // sequential
return { user, orders };
}
// Error handling with try/catch
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
const [orders, settings] = await Promise.all([
fetchOrders(user.id),
fetchSettings()
]);
return { user, orders, settings };
} catch (err) {
console.error("Dashboard load failed:", err);
throw err; // re-throw to let caller handle it
} finally {
setLoading(false);
}
}Parallel vs Sequential
// SEQUENTIAL — each awaits the previous (slow if independent)
const user = await fetchUser(1);
const posts = await fetchPosts(1);
// Total time = time(fetchUser) + time(fetchPosts)
// PARALLEL — start both at once (fast)
const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
// Total time = max(time(fetchUser), time(fetchPosts))The Fetch API
fetch() makes HTTP requests. It returns a Promise.
// Basic GET
const response = await fetch("https://api.example.com/users");
// Always check if the request succeeded
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
const users = await response.json(); // parse JSON bodyFull CRUD with Fetch
const BASE_URL = "https://api.example.com";
// GET — fetch a resource
async function getUser(id) {
const res = await fetch(`${BASE_URL}/users/${id}`);
if (!res.ok) throw new Error(`User ${id} not found`);
return res.json();
}
// POST — create a resource
async function createUser(userData) {
const res = await fetch(`${BASE_URL}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!res.ok) throw new Error("Failed to create user");
return res.json();
}
// PUT — update a resource
async function updateUser(id, updates) {
const res = await fetch(`${BASE_URL}/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates)
});
if (!res.ok) throw new Error("Failed to update user");
return res.json();
}
// DELETE
async function deleteUser(id) {
const res = await fetch(`${BASE_URL}/users/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete user");
}
// With auth token
async function fetchProtected(endpoint) {
const token = localStorage.getItem("token");
const res = await fetch(`${BASE_URL}${endpoint}`, {
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
if (res.status === 401) {
// Token expired — redirect to login
window.location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}Handling Different Response Types
const res = await fetch(url);
res.json() // parse JSON
res.text() // get as string
res.blob() // get as Blob (for images, files)
res.formData() // get as FormData
res.arrayBuffer() // get as binary data
// Reading response headers
res.headers.get("Content-Type");
res.headers.get("X-Request-Id");AbortController — Cancellation
Use this to cancel in-flight requests (e.g., when user navigates away, or search debouncing).
// Cancel a request
const controller = new AbortController();
fetch("https://api.example.com/data", {
signal: controller.signal
});
// Cancel it
controller.abort();
// Timeout pattern
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch (err) {
if (err.name === "AbortError") throw new Error("Request timed out");
throw err;
}
}Practical Pattern: Data Fetching Hook (UI)
// Generic data fetcher with loading/error states
async function fetchData(url, options = {}) {
const { signal, onStart, onSuccess, onError } = options;
onStart?.();
try {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
onSuccess?.(data);
return data;
} catch (err) {
if (err.name !== "AbortError") {
onError?.(err);
}
}
}
// Usage on a button click
const controller = new AbortController();
searchBtn.addEventListener("click", async () => {
const query = searchInput.value;
await fetchData(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
onStart: () => { spinner.style.display = "block"; },
onSuccess: (data) => { renderResults(data); },
onError: (err) => { showError(err.message); },
});
spinner.style.display = "none";
});Key Takeaways
- Use
async/awaitfor readable async code — it's just Promises underneath - Always check
response.ok—fetch()only rejects on network errors, not HTTP errors Promise.allfor parallel independent requests — much faster than sequentialAbortControllerto cancel requests — prevents memory leaks and stale updatestry/catchin async functions — unhandled Promise rejections will crash Node- Never
awaitinside a loop unless the operations must be sequential