Handling Async/Await and Promises in API

Introduction

In modern JavaScript and React applications, working with asynchronous operations is an essential skill. Whenever you fetch data from an API, read from a database, or perform a time-based action, you’re dealing with code that doesn’t execute immediately.

Before the introduction of async/await, developers relied heavily on callbacks and Promises. While these approaches worked, they often resulted in complex, nested code that was hard to read and maintain—commonly referred to as “callback hell.”

With async/await, JavaScript introduced a cleaner and more readable syntax for handling asynchronous operations. Async/await is built on top of Promises but allows you to write asynchronous code that looks and behaves like synchronous code, making it easier to understand and debug.

In this post, we’ll deeply explore how async/await works, how it compares to Promises, and how you can use it to manage API calls—both sequentially and in parallel—in your React or JavaScript applications.


Understanding Asynchronous JavaScript

JavaScript is a single-threaded language, meaning it can execute only one operation at a time. To prevent blocking the main thread, asynchronous operations (like API requests or timers) are handled separately using the event loop and callback queue.

When you fetch data from an API, JavaScript sends the request but doesn’t wait for it to finish before moving on. Instead, it registers a callback or Promise that resolves once the data is returned. This allows the rest of your code to continue running smoothly.


Promises: The Foundation of Async/Await

Before async/await, JavaScript introduced Promises as a more elegant way to handle asynchronous behavior.

A Promise represents a value that will be available in the future—either fulfilled (resolved) or rejected (failed).

Example: Using Promises

fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => {
console.log("Data fetched:", data);
}) .catch((error) => {
console.error("Error fetching data:", error);
});

Here’s what happens step-by-step:

  1. fetch() sends a request to the given URL.
  2. It returns a Promise that resolves when the response is available.
  3. The first .then() extracts the JSON body.
  4. The second .then() handles the result.
  5. .catch() catches any errors in the process.

This pattern works, but chaining multiple .then() calls can become messy in more complex workflows.


The Problem with Promises

While Promises were a huge improvement over callbacks, they introduced their own challenges:

  • Chaining hell: Multiple dependent API calls lead to deeply nested .then() blocks.
  • Error handling: Managing errors across multiple chained calls can get complicated.
  • Readability: It’s often hard to read and reason about asynchronous flows.

To make asynchronous code more readable, JavaScript introduced async/await in ES2017.


Introducing Async/Await

Async/await is syntactic sugar built on top of Promises. It lets you write asynchronous code in a synchronous-looking way.

When you declare a function with the async keyword, it automatically returns a Promise. Inside an async function, you can use the await keyword to pause execution until a Promise is resolved or rejected.

Example: Using Async/Await

async function fetchData() {
  try {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log("Data fetched:", data);
} catch (error) {
console.error("Error fetching data:", error);
} } fetchData();

This code performs the same task as the previous Promise example but is much cleaner and easier to follow.


How Async/Await Works

  1. The async keyword makes a function return a Promise automatically.
  2. The await keyword pauses the function execution until the awaited Promise settles.
  3. The function then resumes and returns the resolved value.
  4. If the Promise is rejected, the await expression throws an error, which can be caught using try...catch.

This approach gives the illusion of synchronous execution while maintaining non-blocking behavior.


Sequential API Calls Using Async/Await

Sometimes you need to make multiple API calls one after another, where each call depends on the result of the previous one.

Example: Sequential Calls

async function getUserAndPosts() {
  try {
const userResponse = await fetch("https://api.example.com/user/1");
const user = await userResponse.json();
const postsResponse = await fetch(
  https://api.example.com/users/${user.id}/posts
);
const posts = await postsResponse.json();
console.log("User:", user);
console.log("Posts:", posts);
} catch (error) {
console.error("Error fetching data:", error);
} } getUserAndPosts();

Here, the second API call depends on the result of the first (user.id). Async/await ensures that the first request completes before the second starts, creating a clean and predictable flow.


Parallel API Calls Using Async/Await

If multiple API calls are independent, you can run them in parallel to save time using Promise.all().

Example: Parallel Calls

async function fetchMultipleData() {
  try {
const [users, posts, comments] = await Promise.all([
  fetch("https://api.example.com/users").then((res) => res.json()),
  fetch("https://api.example.com/posts").then((res) => res.json()),
  fetch("https://api.example.com/comments").then((res) => res.json()),
]);
console.log("Users:", users);
console.log("Posts:", posts);
console.log("Comments:", comments);
} catch (error) {
console.error("Error fetching data:", error);
} } fetchMultipleData();

In this case:

  • All API requests are started simultaneously.
  • The function waits for all of them to complete using Promise.all().
  • If any of them fail, the catch block will handle the error.

Parallel execution greatly improves performance when dealing with multiple independent requests.


Error Handling in Async/Await

The easiest way to handle errors in async/await is with a try...catch block.

Example: Error Handling

async function fetchData() {
  try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) throw new Error("Network response was not ok");
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error:", error.message);
} }

This method ensures that both network errors and application-level issues are properly caught and handled.


Handling Multiple Errors with Promise.allSettled()

Sometimes you want to make multiple API calls, but you don’t want one failure to stop the others.

Promise.allSettled() is perfect for that—it waits for all Promises to settle (either fulfilled or rejected) and returns their results.

Example: Using Promise.allSettled()

async function fetchData() {
  const results = await Promise.allSettled([
fetch("https://api.example.com/data1"),
fetch("https://api.example.com/data2"),
fetch("https://api.example.com/data3"),
]); results.forEach((result, index) => {
if (result.status === "fulfilled") {
  console.log(Request ${index + 1} succeeded.);
} else {
  console.error(Request ${index + 1} failed:, result.reason);
}
}); } fetchData();

Unlike Promise.all(), this method does not throw an error if one request fails. Instead, it provides a complete summary of all results.


Async/Await in React Components

Async/await is widely used in React components, especially when fetching data from APIs.

The most common pattern is to call async functions inside the useEffect hook.

Example: Fetching Data in useEffect

import React, { useEffect, useState } from "react";

function UsersList() {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
async function loadUsers() {
  try {
    const response = await fetch("https://api.example.com/users");
    const data = await response.json();
    setUsers(data);
  } catch (err) {
    setError("Failed to load users");
  }
}
loadUsers();
}, []); if (error) return <p>{error}</p>; return (
&lt;div&gt;
  &lt;h2&gt;User List&lt;/h2&gt;
  {users.map((user) =&gt; (
    &lt;p key={user.id}&gt;{user.name}&lt;/p&gt;
  ))}
&lt;/div&gt;
); } export default UsersList;

Here, async/await helps make the data fetching logic clear and easy to maintain within a React component.


Sequential API Calls in React

Sometimes you need to fetch one piece of data before another (e.g., user info before user posts).

import React, { useEffect, useState } from "react";

function UserPosts() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
async function fetchPosts() {
  try {
    const userResponse = await fetch("https://api.example.com/user/1");
    const user = await userResponse.json();
    const postsResponse = await fetch(
      https://api.example.com/users/${user.id}/posts
    );
    const postsData = await postsResponse.json();
    setPosts(postsData);
  } catch (error) {
    console.error("Error:", error);
  }
}
fetchPosts();
}, []); return (
&lt;div&gt;
  &lt;h2&gt;User Posts&lt;/h2&gt;
  {posts.map((post) =&gt; (
    &lt;p key={post.id}&gt;{post.title}&lt;/p&gt;
  ))}
&lt;/div&gt;
); } export default UserPosts;

This demonstrates sequential async logic integrated cleanly within a React component.


Parallel API Calls in React

When fetching multiple unrelated datasets, parallel API calls improve performance.

import React, { useEffect, useState } from "react";

function Dashboard() {
  const [users, setUsers] = useState([]);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
async function loadDashboardData() {
  try {
    const &#91;usersData, postsData] = await Promise.all(&#91;
      fetch("https://api.example.com/users").then((r) =&gt; r.json()),
      fetch("https://api.example.com/posts").then((r) =&gt; r.json()),
    ]);
    setUsers(usersData);
    setPosts(postsData);
  } catch (error) {
    console.error("Error loading data:", error);
  }
}
loadDashboardData();
}, []); return (
&lt;div&gt;
  &lt;h2&gt;Dashboard&lt;/h2&gt;
  &lt;p&gt;Total Users: {users.length}&lt;/p&gt;
  &lt;p&gt;Total Posts: {posts.length}&lt;/p&gt;
&lt;/div&gt;
); } export default Dashboard;

With Promise.all(), both requests happen simultaneously, reducing wait time.


Retrying Failed API Calls

Sometimes, network issues can cause temporary failures. It’s often helpful to retry failed requests automatically.

Example: Retry Logic

async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i < retries; i++) {
try {
  const response = await fetch(url);
  if (!response.ok) throw new Error("Failed to fetch");
  return await response.json();
} catch (error) {
  if (i === retries - 1) throw error;
  console.warn(Retrying (${i + 1}/${retries})...);
}
} }

This pattern ensures that transient errors don’t crash your application immediately.


Handling Loading States

In React, you should always track loading states when performing async operations.

import React, { useEffect, useState } from "react";

function DataLoader() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
async function loadData() {
  const response = await fetch("https://api.example.com/data");
  const result = await response.json();
  setData(result);
  setLoading(false);
}
loadData();
}, []); return (
&lt;div&gt;
  {loading ? &lt;p&gt;Loading...&lt;/p&gt; : &lt;pre&gt;{JSON.stringify(data, null, 2)}&lt;/pre&gt;}
&lt;/div&gt;
); } export default DataLoader;

Async/Await vs Promises: Comparison

FeaturePromisesAsync/Await
SyntaxChained .then() and .catch()Looks like synchronous code
ReadabilityHarder for complex flowsEasier and cleaner
Error Handling.catch() methodtry...catch block
Sequential CallsMore verboseSimple and intuitive
Parallel CallsUses Promise.all()Same approach, more readable
DebuggingStack traces harder to followEasier with async functions

Common Mistakes to Avoid

  1. Using await outside an async function — You can only use await inside functions declared with async.
  2. Forgetting to handle errors — Always wrap your async code in try...catch.
  3. Not using Promise.all() for parallel requests — Avoid unnecessary sequential execution when not needed.
  4. Blocking the UI — Never use synchronous code (like while loops) with async operations in React.

Performance Tips

  • Use parallel requests whenever possible.
  • Cache API responses to avoid redundant calls.
  • Use lazy loading to fetch data only when required.
  • Implement error boundaries and fallback UI for better user experience.

Comments

Leave a Reply

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