Skip to content

Async JavaScript: Promises and Fetch

💡
Before you start

You need Node installed, and to know how to run a .js file. If node --version in a terminal prints a version number, you are ready. If it does not — or if you have never opened a terminal — do Getting Started with JavaScript first; it installs Node and runs your first program in about fifteen minutes. Nothing else is needed.

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.

Now Do It Yourself: Five Steps

Waiting is the whole subject, so these steps make you watch it happen: the order things really run in, one wait, two waits done wrongly and then rightly, a failure caught properly, and the single most common async bug there is. They use a timer rather than a real network request on purpose — a timer needs no internet, and it gives you the same numbers every run, so you can tell a lesson from a slow connection. fetch behaves exactly the same way; only the thing you are waiting for changes. Every output below is exactly what Node printed.

1
Watch the order that surprises everyone

Go: open a terminal, cd to a folder you can write in, and create a file called later.js.

Do: type these three lines and run node later.js.

console.log("first");
setTimeout(() => console.log("second (after 0 ms)"), 0);
console.log("third");

You should see: first, then third, then second (after 0 ms) — in that order, every time. Zero milliseconds does not mean “now”; it means “as soon as the current work is finished”. JavaScript runs your straight-line code to the end first, and only then picks up anything that was scheduled. That single rule explains most async confusion.

If not: if you see second in the middle, you are not running this in a normal JavaScript engine — check you saved the file and ran the one you edited. If nothing prints at all, the file is empty or you ran a different filename; ls in that folder and look.

2
Wait for something on purpose

Go: same file, replace the contents.

Do: type these ten lines and run it.

function wait(ms, label) {
  return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
async function main() {
  console.log("starting");
  const result = await wait(300, "tea is ready");
  console.log(result);
  console.log("finished");
}
main();

You should see: starting, a visible pause of about a third of a second, then tea is ready, then finished. A promise is a receipt for a value that has not arrived yet; resolve(label) is what fills it in, and await is what unwraps it. Note where async had to go: await is only legal inside a function marked async.

If not: SyntaxError: await is only valid in async functions and the top level bodies of modules means you used await in an ordinary function — add async in front of function. If the program ends instantly with no pause, you left the brackets off main: main; merely mentions the function, main(); runs it.

3
Stop waiting in single file

Go: same file, replace the contents.

Do: type these thirteen lines and run it.

function wait(ms, label) {
  return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
async function main() {
  let start = Date.now();
  await wait(300, "a");
  await wait(300, "b");
  console.log("one after the other:", Math.round((Date.now() - start) / 100) * 100, "ms");

  start = Date.now();
  await Promise.all([wait(300, "a"), wait(300, "b")]);
  console.log("both at once:", Math.round((Date.now() - start) / 100) * 100, "ms");
}
main();

You should see: one after the other: 600 ms, then both at once: 300 ms. Same two jobs, half the time. Two awaits on separate lines mean the second one has not even started until the first finishes; Promise.all starts both immediately and waits for the slower one. When a page feels slow, this is very often why — independent requests queued behind each other for no reason.

If not: if both lines report roughly the same number, check that the second block really uses Promise.all([...]) with both calls inside one array. Writing await in front of each call inside the array puts you straight back to single file. Small variations like 610 are normal — timers are approximate, which is why the numbers are rounded to the nearest hundred.

4
Catch a failure instead of crashing

Go: same file, replace the contents.

Do: type these twenty lines and run it.

function loadUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 1) resolve({ id: 1, name: "Sam" });
      else reject(new Error(`no user with id ${id}`));
    }, 100);
  });
}
async function main() {
  try {
    const user = await loadUser(1);
    console.log("got:", user.name);
    const missing = await loadUser(99);
    console.log("never reached:", missing);
  } catch (err) {
    console.log("caught:", err.message);
  } finally {
    console.log("done either way");
  }
}
main();

You should see: got: Sam, then caught: no user with id 99, then done either way. The line printing never reached never runs: the moment an awaited promise rejects, control jumps straight to catch, skipping the rest of the try. finally runs whichever way it went, which is where cleanup belongs — closing a file, hiding a loading spinner.

If not: take the try/catch away and Node prints the error with a full stack trace and exits without ever reaching done either way. That is what an unhandled rejection looks like, and in a browser it is the same failure with a red console message instead. If err.message prints undefined, something rejected with a plain string rather than new Error(...) — always reject with a real Error.

5
Meet the bug you will write at least once

Go: same file, replace the contents.

Do: type these eleven lines and run it.

function wait(ms, label) {
  return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
async function main() {
  const forgotten = wait(100, "tea");
  console.log(forgotten);
  const awaited = await wait(100, "tea");
  console.log(awaited);
  console.log(typeof forgotten.then);
}
main();

You should see: Promise { <pending> }, then tea, then function. The only difference between the two is the missing await. Memorise the first line. When a value prints as Promise { <pending> }, or a field on it is mysteriously undefined, you forgot an await — you are holding the receipt instead of the thing. The last line shows what a promise really is: an object with a .then method on it.

If not: if the first line prints tea as well, you added await to both. If you see Promise { 'tea' } instead of <pending>, the wait had already finished before the log ran — raise the 100 to 500 and the pending state becomes easy to catch.

🎉
Check yourself before moving on

Without scrolling up: a colleague reports that user.name is undefined, yet logging user on the line before shows Promise { <pending> }. What is wrong, and where? Answer: the call that produced user is missing its await. They are reading .name off the promise rather than off the value inside it, and a promise has no name.

Now do it without the page: rewrite step 3’s first block so that three waits of 300 ms finish in about 300 ms rather than 900. One array, one Promise.all, one await — and check the printed number, not your expectation.

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.