Skip to main content
โšก Calmops

Understanding Promises & async/await โ€” A Practical Guide

Asynchronous code is part of every modern web application. Promises and async/await give us composable, readable ways to work with operations that complete in the future โ€” network requests, timers, file I/O, and more.

This article builds a practical mental model of Promises, shows how promise chains work, explains robust error handling strategies, and demonstrates common async patterns you can reuse today (parallelism, sequencing, timeouts, cancellation and retries).


1) Quick mental model: what a Promise is ๐Ÿง 

  • A Promise represents the eventual completion (fulfill) or failure (reject) of an asynchronous operation and yields a value or an error.
  • A promise has three states: pending, fulfilled, rejected. Once fulfilled or rejected it is settled and can’t change.
  • Promises are chainable and schedule follow-up work on the JavaScript microtask queue โ€” this gives predictable ordering compared to plain callbacks.

When you call an async API that returns a Promise, you can attach handlers with .then(), .catch(), and .finally() or use await from an async function.

Core abbreviations & terms (quick glossary)

  • API โ€” Application Programming Interface; the backend HTTP endpoints your front end talks to.
  • AJAX โ€” Asynchronous JavaScript and XML; legacy term for browser async HTTP requests (now usually JSON).
  • HTTP โ€” HyperText Transfer Protocol, the transport used for most web APIs.
  • JSON โ€” JavaScript Object Notation, common payload format for APIs.
  • CORS โ€” Cross-Origin Resource Sharing; browser security model that impacts cross-origin fetches.
  • Microtask โ€” a job queued for the microtask queue (e.g., an awaited Promise resolution). Microtasks run between macrotasks and ensure predictable ordering.
  • Macrotask (task) โ€” a task queued by things like setTimeout, I/O, UI events. The event loop alternates running task -> microtasks -> rendering.

If you want to go deep on scheduling, see the MDN event loop page and Jake Archibald’s “Tasks, microtasks, and queues” article (links in Further reading).

Example: simple promise

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait(100).then(() => console.log('done after 100ms'));

This wait helper returns a Promise that resolves after a delay โ€” a common primitive for demos and tests.


2) Promise chains: sequencing async operations โœ…

Promise chains let you run asynchronous tasks in sequence without deeply nested callbacks. Each .then() receives the previous value and may return a value or another Promise.

Basic chain

fetch('/api/user')
  .then(res => res.json())
  .then(user => fetch(`/api/profile/${user.id}`))
  .then(res => res.json())
  .then(profile => console.log('profile', profile))
  .catch(err => console.error('Something failed', err));
  • If a .then() returns a non-promise, the next .then() gets that value.
  • If a .then() returns a Promise, the chain pauses until it settles and then continues with its fulfillment value.
  • Errors thrown or rejected Promises anywhere in the chain jump to the nearest .catch().

Returning values vs returning promises

Promise.resolve(2)
  .then(x => x + 3)            // returns 5 (sync)
  .then(x => Promise.resolve(x * 2)) // returns a Promise that resolves to 10
  .then(console.log);          // logs 10

When to use chains

  • Use promise chains when you need straightforward sequencing and prefer functional composition (especially in library code).
  • Chains are also useful when you want to construct pipelines where handlers may be added or removed dynamically.

But for application code, async/await is often more readable โ€” Promise chains help you understand what’s happening under the hood.

Example: chaining with error recovery

fetch('/api/config')
  .then(res => {
    if (!res.ok) throw new Error('config fetch failed');
    return res.json();
  })
  .then(cfg => initializeApp(cfg))
  .catch(err => {
    console.error('Failed to boot gracefully, falling back to defaults', err);
    return initializeApp({ default: true });
  });

This pattern lets you recover from an intermediate failure (returning a fallback value) and continue the chain.


3) async/await: making async code read like sync code โœจ

async functions implicitly return a Promise. Inside an async function, use await to pause until a Promise settles and extract its value.

async function fetchProfile() {
  const res = await fetch('/api/user');
  const user = await res.json();
  const profileRes = await fetch(`/api/profile/${user.id}`);
  return profileRes.json();
}

fetchProfile().then(profile => console.log(profile));

Benefits:

  • Linear control flow; easier to read and reason about.
  • Better stack traces in modern environments.

Important: await only pauses the async function โ€” it doesn’t block the entire thread. Concurrent unrelated work can continue.

Example: compare sequential vs parallel with timing

async function sequential(ids) {
  const out = [];
  for (const id of ids) {
    const res = await fetch(`/api/item/${id}`);
    out.push(await res.json());
  }
  return out;
}

async function parallel(ids) {
  const promises = ids.map(id => fetch(`/api/item/${id}`).then(r => r.json()));
  return Promise.all(promises);
}

// Usage: sequential will wait for each fetch in order; parallel fires them together

Prefer parallel when operations are independent and you want latency savings; prefer sequential when order or rate limits matter.


4) Error handling: catch, try/catch, and propagation ๐Ÿ›ก๏ธ

Errors propagate differently depending on style, but the rules are simple:

  • In Promise chains, use .catch() (or a final .then() with two args) to handle rejections.
  • With async/await, put await calls inside try/catch blocks to catch thrown errors.

Promise chain error handling

doSomething()
  .then(step => doNext(step))
  .then(final => console.log('done', final))
  .catch(err => console.error('error anywhere in chain', err));

Placement matters: a .catch() at the end handles any reject/throw in the chain. You can also handle errors locally with an earlier .catch() and continue by returning a recovery value.

fetch('/unstable')
  .then(res => res.json())
  .catch(err => ({ fallback: true })) // recover and continue
  .then(data => console.log('data or fallback', data));

async/await error handling

async function load() {
  try {
    const res = await fetch('/api/data');
    const data = await res.json();
    return data;
  } catch (err) {
    console.error('failed to load', err);
    // Optionally rethrow or return a default value
    throw err; // preserve rejection for caller
  }
}

Tip: avoid swallowing errors silently. If you catch and don’t rethrow, make the recovery intentional and documented.

finally

Both Promises and async functions support finally (cleanup code that runs regardless of outcome):

doSomething()
  .finally(() => console.log('cleanup'));

// with async/await
async function demo() {
  try {
    await doSomething();
  } finally {
    console.log('cleanup');
  }
}

Example: fine-grained error handling in async code

async function getUserProfile(id) {
  try {
    const res = await fetch(`/api/user/${id}`);
    if (res.status === 404) return null; // explicit business logic handling
    return await res.json();
  } catch (err) {
    // log and rethrow so callers can decide
    console.error('network or parsing error', err);
    throw err;
  }
}

This shows a mix: handle expected conditions (404) locally but rethrow unexpected network/parsing errors for the caller to surface.


5) Common async patterns (and how to do them well) โš™๏ธ

Below are patterns youโ€™ll use frequently with suggested implementations and pitfalls to avoid.

a) Parallel execution โ€” do multiple independent tasks at once

Use Promise.all() when tasks are independent and you want to wait for all.

async function loadAll() {
  const [users, posts, comments] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/comments').then(r => r.json()),
  ]);
  return { users, posts, comments };
}

Notes:

  • Promise.all() rejects fast โ€” if any item rejects, the whole thing rejects. Use Promise.allSettled() if you need per-item results without fast failure.
const results = await Promise.allSettled([p1, p2, p3]);
results.forEach(r => console.log(r.status));

b) Sequential execution โ€” one after another

When you must run tasks in order (e.g., dependent requests), for..of with await is clear:

for (const id of ids) {
  const res = await fetch(`/api/thing/${id}`);
  const data = await res.json();
  console.log(data);
}

Avoid using Array.prototype.forEach with async callbacks โ€” forEach does not await Promises.

Example pitfall: forEach with async

// โŒ this does not await the async callbacks
ids.forEach(async id => {
  const r = await fetch(`/api/${id}`);
  console.log(await r.json());
});

// โœ… use for..of if order matters
for (const id of ids) {
  const r = await fetch(`/api/${id}`);
  console.log(await r.json());
}

c) Racing & timeouts โ€” Promise.race() and timeout helpers

Promise.race() resolves or rejects with the first settled Promise. A common use is applying a timeout to an operation.

function timeout(ms) {
  return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms));
}

async function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url).then(r => r.json()),
    timeout(ms),
  ]);
}

// Usage
try {
  const data = await fetchWithTimeout('/api/slow', 3000);
  console.log(data);
} catch (err) {
  console.error('request failed or timed out', err);
}

Caveat: Promise.race() doesn’t cancel the loser โ€” the underlying work still runs unless the API supports cancellation (see next section).

Example: fetch with AbortController and timeout

async function fetchWithTimeout(url, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

This approach cancels the fetch when the timeout fires (if the environment supports AbortController).

d) Cancellation โ€” AbortController & cooperative cancellation

Modern web APIs like fetch accept an AbortSignal for cancellation. You should design cancellable workflows around cooperative cancellation signals.

const controller = new AbortController();
fetch('/api/stream', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') console.log('request cancelled');
    else throw err;
  });

// Later: cancel
controller.abort();

For non-cancellable APIs, you can ignore results after a cancellation gate, but be aware of resource usage.

Example: cooperative cancellation gate

let active = true;
async function doWork() {
  for await (const chunk of asyncStream()) {
    if (!active) break; // cooperative
    // process chunk
  }
}

function cancelWork() { active = false; }

When an API doesn’t natively support cancellation you can gate results, but the underlying work still consumes resources until it finishes.

e) Retries & backoff

Network requests commonly benefit from a retry strategy with exponential backoff.

async function retry(fn, attempts = 3, delay = 200) {
  let i = 0;
  while (i < attempts) {
    try {
      return await fn();
    } catch (err) {
      i++;
      if (i === attempts) throw err;
      await new Promise(r => setTimeout(r, delay * Math.pow(2, i - 1)));
    }
  }
}

// usage
await retry(() => fetch('/api/unstable').then(r => r.json()), 4, 300);

Be conservative with retries (don’t retry on 4xx client errors) and add jitter to avoid thundering herds in real systems.

Example: retry with jitter and client-error check

function isRetriable(err, res) {
  if (res && res.status >= 500) return true;
  if (err && err.name === 'FetchError') return true;
  return false;
}

async function retry(fn, attempts = 3, base = 200) {
  let i = 0;
  while (i < attempts) {
    try {
      return await fn();
    } catch (err) {
      i++;
      if (i === attempts || !isRetriable(err)) throw err;
      const jitter = Math.random() * base;
      await new Promise(r => setTimeout(r, base * Math.pow(2, i - 1) + jitter));
    }
  }
}

6) Practical tips and common pitfalls โš ๏ธ

  • Don’t mix await inside Array.forEach โ€” use for..of or Promise.all.
  • Remember Promise.all fails fast โ€” use allSettled or map to recoverable Promises if you need partial success.
  • Avoid swallowing errors โ€” always handle or intentionally rethrow errors with clear reasoning.
  • Use AbortController for cancellable work when possible (fetch, streams, etc.).
  • Write small helpers (timeout, retry, wait) and test them; they make your async code clearer and consistent.

Common pitfalls

  • Unexpected concurrency: accidentally running many requests at once (watch out for Promise.all with unbounded arrays).
  • Silent swallowed errors: .catch() that logs and returns nothing hides problems โ€” prefer explicit recovery values.
  • Mixing sync and async control flow without care: e.g., assuming forEach awaits callbacks.
  • Missing cancellation for user-triggered actions (search as-you-type) can cause racey UI updates.

Best Practices

  • Favor async/await for application code readability; use .then() pipelines in small composable utilities when convenient.
  • Fail fast on unrecoverable errors and provide purposeful recovery on recoverable errors.
  • Use AbortController and cooperative cancellation for long-running or user-cancellable work.
  • Add timeouts to external I/O and careful retry policies with exponential backoff + jitter.
  • Limit parallelism when dealing with rate-limited endpoints (see p-limit or implement a small pool).

Example: simple concurrency limiter (pool)

function pool(limit) {
  const queue = [];
  let active = 0;
  const next = () => {
    if (queue.length === 0 || active >= limit) return;
    active++;
    const { fn, resolve, reject } = queue.shift();
    fn()
      .then(resolve, reject)
      .finally(() => { active--; next(); });
  };
  return fn => new Promise((resolve, reject) => {
    queue.push({ fn, resolve, reject });
    next();
  });
}

// usage
const limited = pool(4);
await Promise.all(urls.map(u => limited(() => fetch(u).then(r => r.json()))));

This avoids firing all requests at once.


7) Short checklist before shipping async code โœ…

  • Are long-running operations guarded by timeouts?
  • Are you using cancellation where user actions can abort work?
  • Do errors propagate to a place where they can be logged or surfaced to the user?
  • Do you need parallelism (Promise.all) or ordered processing (for..of + await)?
  • Have you avoided common gotchas like forEach + async?

Wrap up โ€” keep Promises under control

Promises and async/await are essential tools in modern JavaScript. Use Promise chains when composing pipeline-style logic, and prefer async/await for application-level code for readability. Combine these with solid error handling, timeouts, cancellation, and retry strategies to build reliable asynchronous systems.


Deployment flow (text diagram)

Example basic flow for a typical single-page app talking to an API:

browser (frontend) -> CDN/Edge -> Web server (static + edge functions) -> API gateway -> backend services (microservices/datastore)

When dealing with async behavior in the frontend, ensure timeouts and retries are coordinated with server-side idempotency and rate-limit headers so retries don’t cause duplicate side-effects.


Pros / Cons and alternatives

Pros

  • Readability: async/await makes async code look sequential and easier to reason about.
  • Composability: Promises are chainable and integrate with many APIs.
  • Standardized: native across modern browsers and Node.js without runtime dependencies.

Cons

  • No built-in cancellation (handled by AbortController when supported).
  • Abstraction can hide concurrency semantics (easy to accidentally start too many tasks in parallel).
  • For stream processing, Promises are less expressive than Observables or async iterators.

Alternatives

  • Callbacks: older, more error-prone approach (callback hell). Avoid for new code.
  • RxJS / Observables: great for event streams and complex compositional async logic (but heavier and learning curve).
  • Async Iterators (for await / ReadableStream): excellent for streaming scenarios and backpressure.

Further resources


Further reading

Comments