Synchronous vs Asynchronous
By default, JavaScript runs code one line at a time, from top to bottom. This is called synchronous execution. Each line must finish before the next one starts.
// Synchronous — each line runs in order
console.log("First");
console.log("Second");
console.log("Third");
// Output: First, Second, Third
But what happens when an operation takes time — like fetching data from a server, reading a file, or waiting for a timer? If JavaScript waited for every slow operation to finish, the entire page would freeze.
// Asynchronous — some operations happen "later"
console.log("First");
setTimeout(function() {
console.log("Second (after 2 seconds)");
}, 2000);
console.log("Third");
// Output: First, Third, Second (after 2 seconds)
// JavaScript did NOT wait for the timer!
Asynchronous code allows JavaScript to start a long-running operation and continue executing the rest of the code without waiting. When the operation finishes, a callback function runs with the result.
Callbacks and Callback Hell
A callback is a function passed as an argument to another function. The callback runs when the asynchronous operation completes.
// Simple callback example
function fetchData(callback) {
setTimeout(function() {
const data = { name: "Alice", age: 30 };
callback(data); // Call the callback with the result
}, 1000);
}
fetchData(function(result) {
console.log(result); // { name: "Alice", age: 30 }
});
Callbacks work fine for simple cases, but when you need to chain multiple asynchronous operations, you end up with deeply nested code known as "callback hell":
// Callback hell — deeply nested, hard to read
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
getShippingInfo(details.shippingId, function(shipping) {
console.log("Shipping:", shipping);
// Even more nesting if needed...
}, function(error) {
console.error("Shipping error:", error);
});
}, function(error) {
console.error("Details error:", error);
});
}, function(error) {
console.error("Orders error:", error);
});
}, function(error) {
console.error("User error:", error);
});
This pyramid of doom is hard to read, hard to debug, and hard to maintain. Promises were created to solve this problem.
Promises
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states:
- Pending: The operation is still in progress
- Fulfilled (resolved): The operation completed successfully
- Rejected: The operation failed with an error
// Creating a Promise
const myPromise = new Promise(function(resolve, reject) {
// Simulate an async operation
const success = true;
setTimeout(function() {
if (success) {
resolve("Operation succeeded!"); // Fulfilled
} else {
reject("Operation failed!"); // Rejected
}
}, 1000);
});
// Using a Promise
myPromise
.then(function(result) {
console.log(result); // "Operation succeeded!"
})
.catch(function(error) {
console.error(error); // Runs if rejected
});
// A more realistic example
function fetchUser(id) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (id > 0) {
resolve({ id: id, name: "Alice", email: "alice@example.com" });
} else {
reject(new Error("Invalid user ID"));
}
}, 500);
});
}
fetchUser(1)
.then(user => console.log("Found:", user.name))
.catch(error => console.error(error.message));
Promise Chaining
Promises can be chained — each .then() returns a new Promise, allowing
you to perform sequential async operations without nesting.
// Chaining replaces callback hell
fetchUser(1)
.then(user => {
console.log("User:", user.name);
return fetchOrders(user.id); // Returns a new Promise
})
.then(orders => {
console.log("Orders:", orders.length);
return fetchOrderDetails(orders[0].id);
})
.then(details => {
console.log("Details:", details);
})
.catch(error => {
// One catch handles errors from any step
console.error("Something went wrong:", error.message);
});
Promise.all
Run multiple Promises in parallel and wait for all of them to finish:
const promise1 = fetch("/api/users");
const promise2 = fetch("/api/posts");
const promise3 = fetch("/api/comments");
Promise.all([promise1, promise2, promise3])
.then(responses => {
console.log("All requests completed!");
// responses is an array of all results
return Promise.all(responses.map(r => r.json()));
})
.then(([users, posts, comments]) => {
console.log("Users:", users.length);
console.log("Posts:", posts.length);
console.log("Comments:", comments.length);
})
.catch(error => {
// If ANY promise rejects, catch fires immediately
console.error("One request failed:", error);
});
Promise.race
Returns the result of whichever Promise finishes first:
// Useful for timeouts
const fetchWithTimeout = Promise.race([
fetch("/api/slow-endpoint"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), 5000)
)
]);
fetchWithTimeout
.then(response => console.log("Got response in time!"))
.catch(error => console.error(error.message));
Async/Await
async/await is a cleaner syntax for working with Promises.
It makes asynchronous code look and behave like synchronous code.
// Mark a function as async
async function loadUserData() {
// await pauses execution until the Promise resolves
const user = await fetchUser(1);
console.log("User:", user.name);
const orders = await fetchOrders(user.id);
console.log("Orders:", orders.length);
const details = await fetchOrderDetails(orders[0].id);
console.log("Details:", details);
return details; // async functions always return a Promise
}
// Call the async function
loadUserData()
.then(details => console.log("Done!"))
.catch(error => console.error(error));
async/await does not replace Promises — it uses
them under the hood. An async function always returns a Promise.
The await keyword simply pauses execution until a Promise resolves,
making the code easier to read. You can mix both styles as needed.
// Parallel execution with async/await
async function loadDashboard() {
// Start all requests simultaneously
const [users, posts, stats] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/stats").then(r => r.json())
]);
console.log("Users:", users.length);
console.log("Posts:", posts.length);
console.log("Stats:", stats);
}
// Common mistake: sequential instead of parallel
async function loadDashboardSlow() {
// These run one after another — slower!
const users = await fetch("/api/users").then(r => r.json());
const posts = await fetch("/api/posts").then(r => r.json());
const stats = await fetch("/api/stats").then(r => r.json());
}
The Fetch API
fetch() is the modern way to make HTTP requests in JavaScript. It
returns a Promise that resolves to the Response object.
GET Requests
// Basic GET request
const response = await fetch("https://api.example.com/users");
const users = await response.json(); // Parse JSON body
console.log(users);
// With query parameters
const query = new URLSearchParams({ page: 1, limit: 10 });
const response = await fetch(`https://api.example.com/users?${query}`);
const data = await response.json();
POST Requests
// Sending JSON data
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "Alice",
email: "alice@example.com"
})
});
const newUser = await response.json();
console.log("Created user:", newUser);
Other HTTP Methods
// PUT — update a resource
await fetch("/api/users/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice Updated" })
});
// DELETE — remove a resource
await fetch("/api/users/1", {
method: "DELETE"
});
// PATCH — partial update
await fetch("/api/users/1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "newemail@example.com" })
});
Checking Response Status
const response = await fetch("/api/data");
// response.ok is true for status codes 200-299
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
// Common response properties
console.log(response.status); // 200
console.log(response.statusText); // "OK"
console.log(response.headers); // Headers object
console.log(response.url); // The final URL (after redirects)
Error Handling with Async Code
try/catch with async/await
async function fetchUserSafe(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`User not found (${response.status})`);
}
const user = await response.json();
return user;
} catch (error) {
if (error.name === "TypeError") {
// Network error (no internet, server down, etc.)
console.error("Network error:", error.message);
} else {
// Application error (404, 500, etc.)
console.error("Error:", error.message);
}
return null; // Return a safe default
}
}
fetch() only rejects on network failures (no internet, DNS errors).
It does NOT reject on HTTP errors like 404 or 500 — those are considered successful
responses. Always check response.ok or response.status
before using the data. Unhandled errors cause silent failures that are difficult
to debug.
.catch() with Promises
// Using .catch() with Promise chains
fetch("/api/data")
.then(response => {
if (!response.ok) throw new Error("Request failed");
return response.json();
})
.then(data => {
console.log("Data:", data);
})
.catch(error => {
console.error("Error:", error.message);
})
.finally(() => {
// Runs whether the promise resolved or rejected
console.log("Request complete");
hideLoadingSpinner();
});
Handling Multiple Errors
async function loadPageData() {
try {
const [users, posts] = await Promise.all([
fetch("/api/users").then(r => {
if (!r.ok) throw new Error("Failed to load users");
return r.json();
}),
fetch("/api/posts").then(r => {
if (!r.ok) throw new Error("Failed to load posts");
return r.json();
})
]);
return { users, posts };
} catch (error) {
console.error(error.message);
return { users: [], posts: [] }; // Safe defaults
}
}
// Promise.allSettled — never rejects, reports all results
const results = await Promise.allSettled([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/broken-endpoint").then(r => r.json())
]);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.log(`Request ${index} failed:`, result.reason);
}
});
Practical Example: API Data Loader
Let us build a complete data loader that fetches user profiles from an API and displays them on a page with loading states and error handling.
// HTML:
// <div id="user-app">
// <input type="text" id="user-id" placeholder="Enter user ID">
// <button id="load-btn">Load User</button>
// <div id="status"></div>
// <div id="user-card"></div>
// </div>
const loadBtn = document.querySelector("#load-btn");
const userIdInput = document.querySelector("#user-id");
const statusDiv = document.querySelector("#status");
const userCard = document.querySelector("#user-card");
function showStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = type; // "loading", "error", or "success"
}
function renderUser(user) {
userCard.innerHTML = `
<div class="card">
<h3>${user.name}</h3>
<p>Email: ${user.email}</p>
<p>Company: ${user.company.name}</p>
<p>City: ${user.address.city}</p>
</div>
`;
}
async function loadUser() {
const userId = userIdInput.value.trim();
if (!userId) {
showStatus("Please enter a user ID", "error");
return;
}
// Show loading state
showStatus("Loading...", "loading");
userCard.innerHTML = "";
loadBtn.disabled = true;
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (!response.ok) {
if (response.status === 404) {
throw new Error("User not found");
}
throw new Error(`Server error (${response.status})`);
}
const user = await response.json();
renderUser(user);
showStatus(`Loaded user: ${user.name}`, "success");
} catch (error) {
if (error.name === "TypeError") {
showStatus("Network error — check your connection", "error");
} else {
showStatus(error.message, "error");
}
} finally {
loadBtn.disabled = false;
}
}
// Event listeners
loadBtn.addEventListener("click", loadUser);
userIdInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") loadUser();
});
This example demonstrates fetch requests, proper error handling, loading states, and async/await working together in a practical UI component.
Summary
- Synchronous code runs line by line; asynchronous code lets slow operations happen in the background
- Callbacks are simple but lead to deeply nested "callback hell" for sequential operations
- Promises represent future values — use
.then()for success and.catch()for errors - Promise chaining avoids nesting by returning new Promises from each
.then() async/awaitmakes asynchronous code look synchronous and is easier to readfetch()is the modern API for HTTP requests and returns a Promise- Always check
response.ok— fetch does not reject on HTTP errors - Use
try/catchwith async/await or.catch()with Promises for error handling
You can now handle asynchronous operations, fetch data from APIs, and build responsive applications. Next up: Modern ES6+ Features — learn the powerful syntax additions that make JavaScript more expressive and concise.