JavaScript Async Programming Guide: Promises, Async/Await, Errors, and Real API Examples
javascriptasync-programmingweb-developmentapiscoding-tutorial

JavaScript Async Programming Guide: Promises, Async/Await, Errors, and Real API Examples

CCodeacademy.site Editorial Team
2026-11-03
7 min read

Learn JavaScript async programming through API examples covering Promises, async/await, concurrency, retries, cancellation, and errors.

Asynchronous JavaScript lets an application request data, respond to user actions, and perform slow work without freezing the main flow of execution. This guide explains the event loop, Promises, async/await, concurrency, retries, cancellation, and error handling through progressively improved API examples, then gives you a practical maintenance cycle for keeping async code reliable as an application changes.

Overview

JavaScript runs one piece of code at a time on its main thread, but many useful operations take time: network requests, timers, file access, and user interactions. Rather than waiting synchronously for each operation to finish, JavaScript starts the operation and handles its result later. The event loop coordinates this process by moving completed callbacks and Promise reactions into the execution queue when the current stack is clear.

A Promise represents the eventual result of an asynchronous operation. It can be pending, fulfilled, or rejected. A basic request might look like this:

fetch('/api/profile')
  .then(response => {
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }
    return response.json();
  })
  .then(profile => {
    console.log(profile.name);
  })
  .catch(error => {
    console.error('Could not load profile:', error);
  });

The fetch function returns a Promise. The first then checks the HTTP response and converts it to JSON. The next then receives the parsed data, while catch handles a rejected Promise or an error thrown in an earlier handler. Checking response.ok matters because a completed network request is not necessarily a successful application request.

async and await provide a more readable way to write the same control flow. An async function always returns a Promise, and await pauses that function until a Promise settles without blocking unrelated JavaScript work.

async function loadProfile() {
  const response = await fetch('/api/profile');

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return response.json();
}

async function showProfile() {
  try {
    const profile = await loadProfile();
    console.log(profile.name);
  } catch (error) {
    console.error('Could not load profile:', error);
  }
}

For a beginner learning JavaScript, the important distinction is that await makes code look sequential, but the surrounding function remains asynchronous. It does not make a network request instantaneous, and it does not automatically make independent tasks run concurrently.

Maintenance cycle

Async code is easier to maintain when you review it deliberately rather than only fixing it after a production failure. Use a small cycle whenever you add an endpoint, change response data, or revise loading behavior.

1. Define the operation’s contract

Write down what the function receives, what it returns, and which failures callers can expect. Decide whether a missing record is a normal result, an error, or a separate status. Keep transport concerns, such as checking HTTP status, close to the request layer; keep display decisions in the UI or calling layer.

2. Separate independent and dependent work

Sequential awaits are appropriate when one operation needs the result of another:

const account = await loadAccount();
const invoices = await loadInvoices(account.id);

If the operations do not depend on each other, start them together with Promise.all:

const [profile, notifications] = await Promise.all([
  loadProfile(),
  loadNotifications()
]);

This expresses intent clearly and avoids making unrelated work wait in sequence. Use Promise.allSettled instead when every result should be inspected even if one operation fails, such as loading several optional dashboard panels.

3. Add boundaries for failure and time

Every external request should have an intentional error path. For operations that can hang or become irrelevant, use an AbortController:

async function loadResults(signal) {
  const response = await fetch('/api/results', { signal });

  if (!response.ok) {
    throw new Error(`Results request failed: ${response.status}`);
  }

  return response.json();
}

const controller = new AbortController();
loadResults(controller.signal).catch(error => {
  if (error.name !== 'AbortError') {
    console.error(error);
  }
});

// Call this when the view is closed or the request is no longer needed.
controller.abort();

Cancellation is especially useful when a user changes a search term, navigates away, or submits a newer request. It prevents obsolete work from updating the interface after the relevant context has disappeared.

4. Test the unhappy paths

Test rejected requests, invalid JSON, non-success HTTP responses, timeouts or cancellations, and responses that contain missing fields. A short manual test checklist can reveal more than a successful example alone. For repeatable debugging, inspect the browser Network panel, log request identifiers, and avoid logging credentials or private response data.

Signals that require updates

An asynchronous programming guide or codebase should be reviewed when its examples no longer reflect the way the application is built. The following signals are useful triggers:

  • Repeated loading bugs: Buttons can be clicked twice, stale results replace newer results, or loading indicators remain visible after failure.
  • Unclear Promise chains: A chain contains nested callbacks, duplicated error handlers, or a mixture of then and await that makes ownership unclear.
  • Silent failures: A rejected Promise is not awaited or returned, so the caller cannot handle it and the original context is lost.
  • Unbounded retries: A temporary failure causes repeated requests without a limit, delay, or decision about which errors are safe to retry.
  • Changing API behavior: An endpoint changes its response shape, authentication method, status conventions, or pagination model.
  • New concurrency requirements: A feature now loads many records, performs requests in batches, or needs to preserve the order of user actions.

Retries deserve particular care. A retry can help with a temporary network problem, but repeating a state-changing request may create duplicate work. Before adding one, identify whether the operation is safe to repeat, cap the number of attempts, and use a delay that does not overload the service. Do not treat retrying as a substitute for understanding the original error.

Common issues

Forgetting to return or await a Promise

This function finishes before its inner request:

function getUser() {
  fetch('/api/user').then(response => response.json());
}

Callers cannot await the result because the function returns undefined. Return the Promise or use async and await:

function getUser() {
  return fetch('/api/user').then(response => response.json());
}

Confusing network errors with HTTP errors

fetch can resolve with a response whose status indicates failure. Always check the response before parsing or using its data. At the same time, recognize that a rejected Promise usually represents a transport, cancellation, or processing problem rather than every possible server-side status.

Updating the interface with stale data

If a user starts request A and then request B, request A may finish last. Track the current request, cancel obsolete work where possible, or compare a request identifier before applying a result. This is a correctness issue, not merely a visual detail.

Using forEach with an async callback

forEach does not wait for an asynchronous callback. Use a loop when operations must be sequential, or map to Promises and combine them:

const results = await Promise.all(
  items.map(item => processItem(item))
);

For large collections, add a concurrency limit instead of starting every request at once. The right limit depends on the operation and its environment, so measure and document the choice rather than assuming that maximum parallelism is best.

When to revisit

Revisit your async JavaScript notes and examples on a scheduled review cycle, and immediately when search intent or project needs shift. A practical cadence is to review the guide whenever you complete a meaningful API integration, refactor a loading flow, or encounter a recurring race condition. During the review, run every example, confirm that error paths are visible, and check that terminology still matches the code being taught.

For a project, make the next review actionable:

  1. List every external asynchronous operation in the feature.
  2. Mark which operations are dependent and which can run concurrently.
  3. Verify status checks, parsing failures, cancellation, and user-visible error states.
  4. Test rapid repeated actions and slow or failed requests.
  5. Record any API or runtime assumptions beside the example.

Use this guide alongside an API debugging toolkit when inspecting request and response data, and pair it with a project-based JavaScript tutorial when you want to apply the concepts in a complete application. Returning to the checklist after each substantial change keeps asynchronous behavior understandable, testable, and aligned with what users actually do.

Related Topics

#javascript#async-programming#web-development#apis#coding-tutorial
C

Codeacademy.site Editorial Team

Programming Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.