Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises 101 — A Practical, No‑Nonsense Guide

A concise, practical reference to understand Promises, async/await, timing, and common patterns you’ll use daily.

Updated
6 min read
JavaScript Promises 101 — A Practical, No‑Nonsense Guide

1) What is a Promise?

A Promise represents the eventual result of an asynchronous operation.

States

  • pending → operation in progress

  • fulfilled → operation completed successfully (has a value)

  • rejected → operation failed (has a reason/error)

Lifecycle

PendingFulfilled (value)  →  Rejected  (error)

Key idea: A Promise is a container for a future value. It does not execute work by itself—work starts when you call the async function or API that returns it (e.g., fetch()).


2) Where does the work actually happen?

  • In the browser: network stack, timers, Web APIs

  • In Node.js: libuv (thread pool), OS syscalls, timers

The JavaScript thread doesn’t perform the I/O. It waits to be notified when the work completes and then processes the completion via the microtask queue.


3) async functions and await

async function example() {
  // Synchronous until the first await
  const data = await fetch('/api'); // pauses here until the promise settles
  return data; // returned value becomes the resolution value of the async function
}
  • An async function always returns a Promise.

  • Execution runs synchronously until it hits the first await.

  • At await, the function pauses, returning a pending Promise to the caller.

  • When the awaited promise settles, the function resumes after the await.

await doesn’t make the work happen—it waits for and retrieves the result of work already in progress.


4) Await inside vs awaiting the outer function

async function doSomething() {
  await fetch('/api/data'); // awaits inside
  console.log('done');      // runs later when fetch resolves
}

doSomething();             // caller is NOT awaiting
console.log('other work');
// output: "other work" then "done"
  • await inside controls sequencing inside the function.

  • Awaiting the outer function controls whether the caller waits for completion / gets the returned value.

Inside awaits still run even if the caller never awaits the outer function (but beware unhandled rejections; see §10).


5) When should you await?

Await when

  • You need the value:

      const user = await fetchUser();
      render(user);
    
  • You need ordering/atomicity (e.g., show a loader, then hide it only after completion):

      setLoading(true);
      await save();
      setLoading(false);
    
  • You must handle errors with try/catch at this call site.

Don’t await when

  • Fire-and-forget side effects (analytics, logging):

      logEvent().catch(console.error); // at least catch errors
    
  • You want to run tasks in parallel:

      const p1 = fetchA();
      const p2 = fetchB();
      const [a, b] = await Promise.all([p1, p2]);
    

6) Parallel vs Sequential

Sequential (slower)

await task1();
await task2();

Parallel (faster)

const p1 = task1(); // start both
const p2 = task2();
await Promise.all([p1, p2]);

Handling partial failures

const results = await Promise.allSettled([task1(), task2()]);
for (const r of results) {
  if (r.status === 'fulfilled') console.log(r.value);
  else console.error(r.reason);
}

7) Request coalescing (de-dup) pattern

Avoid duplicate in-flight requests for the same key/id.

class Loader<T> {
  private pending = new Map<string, Promise<T>>();
  private cache = new Map<string, T>();

  async get(id: string): Promise<T> {
    if (this.cache.has(id)) return this.cache.get(id)!;
    const p = this.pending.get(id);
    if (p) return p; // reuse in-flight

    const request = (async () => {
      try {
        const value = await fetchThing(id);
        this.cache.set(id, value);
        return value;
      } finally {
        this.pending.delete(id); // runs on success or failure
      }
    })();

    this.pending.set(id, request);
    return request;
  }
}

Notes

  • Async IIFE returns a promise immediately; actual work continues in background.

  • finally clears the in-flight marker regardless of outcome.


8) Error handling

try/catch with await

try {
  const data = await fetchJSON(url);
} catch (err) {
  // handle / show toast / retry
}

.then/.catch

fetchJSON(url)
  .then(data => use(data))
  .catch(err => handle(err));

Top-level (fire-and-forget)

doSideEffect().catch(err => console.error('background failed', err));

9) Unhandled promise rejections

If a promise rejects and nothing awaits or .catch()es it:

  • Browsers: console warning

  • Node.js: unhandled rejection warning; newer Node can terminate the process based on unhandledRejection behavior

Always attach a catch for fire-and-forget operations:

void doSomethingAsync().catch(reportError);

10) Cancellation & timeouts

Promises can’t be canceled intrinsically, but many APIs support AbortController.

const c = new AbortController();
const p = fetch(url, { signal: c.signal });

setTimeout(() => c.abort(), 3000); // cancel after 3s

try {
  const res = await p;
} catch (e) {
  if (e.name === 'AbortError') console.log('aborted');
}

Timeout utility

function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout')), ms))
  ]);
}

11) Microtasks vs Macrotasks (event loop essentials)

  • Microtasks: promise reactions (.then, await continuations)

  • Macrotasks: setTimeout, setInterval, DOM events

Order: After each macrotask tick, the engine drains all microtasks before the next macrotask.

setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// output: microtask, then timeout

12) Common pitfalls & fixes

  • Forgetting to handle rejections

    • Fix: always add .catch(...) on fire-and-forget, or wrap in try/catch.
  • Accidentally running sequentially when you intended parallelism

    • Fix: start tasks first, await with Promise.all.
  • Mutating shared results from cache

    • Fix: return a copy or Object.freeze immutable results.
  • Shallow vs deep clone

    • Use structuredClone when needed; spreading is shallow and strips prototypes.
  • Returning before cleanup

    • Use finally to ensure cleanup always runs.

13) Patterns cheat‑sheet

Start now, await later

const work = doWork();
// ... do other things
const result = await work;

Fire and forget (with error logging)

void doSideEffect().catch(console.error);

Serialize with a mutex-like queue

let last = Promise.resolve();
function enqueue(task) {
  const run = last.then(task, task);
  last = run.catch(() => {});
  return run;
}

Rate limit calls (token bucket / debounce + promises) — out of scope for brevity but good to know.


14) FAQ

Q: If I don’t await a returned promise, does the work still happen?
A: Yes. The work continues in the background. But handle rejections to avoid unhandled warnings.

Q: Why do I need to await later if the promise already resolved?
A: To retrieve the resolved value and to ensure your code runs after completion. Without await, you only have the Promise, not its value.

Q: Does await block the thread?
A: No. It pauses that async function and yields control to the event loop.

Q: Is await the same as .then?
A: Semantically similar. await is syntax sugar for chaining .then and handling errors via try/catch.

Q: Will finally run if a promise rejects?
A: Yes—finally always runs.


15) Minimal mental model

  1. Start async work → you get a Promise immediately.

  2. Work proceeds outside the JS thread.

  3. When done, JS schedules a microtask to resume your code.

  4. Use await/.then to: (a) get the value, (b) sequence dependent code, (c) handle errors.


16) Mini-lab: log the timeline

async function demo() {
  console.log('A: start');
  const p = fetch('/api/slow');
  console.log('B: after starting fetch');
  const res = await p;           // pauses here
  console.log('C: after await');
  const json = await res.json();
  console.log('D:', json);
}

demo();
console.log('E: outside');
// Possible order: A, B, E, C, D

Use this to verify your understanding of ordering.


17) Production tips

  • Prefer Promise.all for independent async work.

  • Always catch background promises.

  • Centralize error reporting for async failures.

  • Consider timeouts and cancellation for network requests.

  • Use finally for cleanup (spinners, locks, in-flight maps).


You’ve got this. Promises are just: start work → get a token (promise) → resume when it’s done. Master the timing, and the rest follows naturally.

JavaScript Promises 101 — A Practical, No‑Nonsense Guide