30 Days of JavaScript: Asynchronous JavaScript and Promises, Day 5

30 Days of JavaScript: Building a Simple Weather App
30 Days of JavaScript: Asynchronous JavaScript and Promises, Day 5

Learn asynchronous JavaScript in Day 5 of our tutorial series. Understand callbacks, Promises, async/await, and the Fetch API for seamless web application performance.

You click a button and the whole page freezes for a second or two. We’ve all shipped that bug at least once. The cause is almost always the same: JavaScript runs on a single thread, so the moment you make it wait on something slow, your users wait too.

Welcome to Day 5 of our 30 Days of JavaScript series. Today is about the tools that keep that thread free: asynchronous programming, Promises, and async/await. Here’s the plan:

Table of Contents

By the end you’ll know how to fetch data, handle errors, and keep the interface responsive while slow work happens in the background. Let’s get into it.

Understanding Asynchronous Programming

Some work takes time: fetching data from an API, reading a file, waiting on a database. If JavaScript stopped and waited for each of those, nothing else could run. No clicks, no scrolling, no animation. The page would just sit there.

Asynchronous programming is how you avoid that. You kick off the slow work, let it run in the background, and get told when it’s done. The main thread stays free the whole time. That’s the entire idea. Everything below is a different way to express it.

Callbacks, Promises, and Async/Await

Callbacks came first. A callback is just a function you hand to another function, to be run later once the slow work finishes. Here’s the simplest version, a greeting printed after a one-second delay:

JS
/**
 * Function that prints a greeting message after a delay.
 * @param {Function} callback - The callback function to execute after the delay.
 */
function printGreeting(callback) {
  setTimeout(() => {
    const greeting = 'Hello, world!';
    callback(greeting);
  }, 1000);
}
/**
 * Logs the greeting to the console.
 * @param {string} greeting - The greeting message.
 */
printGreeting((greeting) => {
  console.log(greeting);
});

Same idea, one step closer to real life. This time we fetch data with the older XMLHttpRequest, and the callback takes two arguments: an error and the data. That error-first shape was the convention for years:

JS
/**
 * Fetch data from the provided URL and handle the response using a callback.
 * @param {string} url - The URL to fetch data from.
 * @param {Function} callback - The callback function to handle the response or error.
 */
function fetchData(url, callback) {
  const xhr = new XMLHttpRequest();
  xhr.onreadystatechange = () => {
    if (xhr.readyState === 4) {
      if (xhr.status === 200) {
        callback(null, JSON.parse(xhr.responseText));
      } else {
        callback(new Error(`Request failed with status ${xhr.status}`));
      }
    }
  };
  xhr.open('GET', url);
  xhr.send();
}
/**
 * Callback function to handle the response or error.
 * @param {Error|null} error - The error object, if any.
 * @param {Object|null} data - The fetched data.
 */
fetchData('https://jsonplaceholder.typicode.com/todos/1', (error, data) => {
  if (error) {
    console.error('Error:', error);
  } else {
    console.log('Data:', data);
  }
});

Callbacks work, but they don’t nest well. Chain a few together (fetch this, then that, then update the page) and your code marches off toward the right edge of the screen. People named it “callback hell” for a reason.

Promises were the fix. A Promise is an object that stands in for a value you don’t have yet. It sits in one of three states: pending while the work runs, fulfilled when it succeeds, or rejected when it fails. You read the result with .then() for success and .catch() for failure, and because each of those returns a new Promise, you can chain them in a flat line instead of a nested pyramid.

async/await is newer syntax layered on top of Promises. Mark a function async, and inside it you can await a Promise: the line pauses until the Promise settles, then hands you the resolved value as if it were a normal return. If the Promise rejects, the await throws, so you catch it with an ordinary try...catch. Same machinery underneath, but it reads like plain top-to-bottom code. We’ll use both styles below.

Fetch API and Making HTTP Requests

The Fetch API is the modern way to make HTTP requests in the browser. It’s built on Promises, so it drops straight into .then() chains or async/await. Here’s the beginner version, the same request as before with a fraction of the code:

JS
/**
 * Fetch data from the provided URL using the Fetch API.
 * @param {string} url - The URL to fetch data from.
 */
fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then((response) => response.json())
  .then((data) => {
    console.log('Data:', data);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

One honest caveat, and it catches almost everyone: fetch() only rejects on a genuine network failure, such as a dropped connection or a badly formed URL. A 404 or a 500 from the server still counts as a completed response, so it lands in .then(), not .catch(). If you want to treat those as errors, check response.ok (true for status codes 200 to 299) or read response.status yourself, then throw. Skip that check and your “working” code will happily log a 404 error page as if it were real data.

Now a more realistic job. This function pages through an API with async/await, keeps asking for the next page until one comes back short, and combines everything into a single array:

JS
/**
 * Fetch all paginated data from the provided URL.
 * @param {string} url - The base URL to fetch data from.
 * @param {number} [limit=10] - The number of items per page.
 * @returns {Promise<Array>} A promise that resolves to an array of all fetched data.
 */
async function fetchAllData(url, limit = 10) {
  let currentPage = 1;
  let hasMoreData = true;
  const allData = [];
  while (hasMoreData) {
    const response = await fetch(`${url}?_page=${currentPage}&_limit=${limit}`);
    const data = await response.json();
    allData.push(...data);
    currentPage++;
    if (data.length < limit) {
      hasMoreData = false;
    }
  }
  return allData;
}
/**
 * Fetches and logs all items from the paginated API.
 */
fetchAllData('https://jsonplaceholder.typicode.com/todos')
  .then((data) => {
    console.log(`Fetched ${data.length} items`);
  })
  .catch((error) => {
    console.error('Error:', error);
  });
Conclusion

That’s the async toolkit: callbacks, Promises, and async/await, plus the Fetch API for pulling data in from the outside world. Callbacks show you where this all started. Promises give you a cleaner way to chain steps and handle failure. async/await makes asynchronous code read top to bottom, the way you already think about it.

The one thing worth committing to memory: fetch() won’t flag an HTTP error for you. Check response.ok every time, and you’ll dodge a whole category of silent bugs.

Next up we shift from timing to syntax.

What’s Next?

In Day 6 we’ll cover the modern JavaScript features that make everyday code shorter and clearer: arrow functions, template literals, and destructuring. Small tools, but you’ll reach for them constantly. See you there.

Next: 30 Days of JavaScript: Modern JavaScript (ES6+), Day 6

Leave a Comment

Your email address will not be published. Required fields are marked *


Scroll to Top