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 is syntactic sugar over Promises

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
    }
}
⚠️
Always handle fetch errors

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/await makes asynchronous code look synchronous and is easier to read
  • fetch() is the modern API for HTTP requests and returns a Promise
  • Always check response.ok — fetch does not reject on HTTP errors
  • Use try/catch with async/await or .catch() with Promises for error handling
🎉
Async JavaScript conquered!

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.