Skip to main content

Dec 2026 · Guide · ~10 min read

JavaScript Promises vs Async/Await — Explained Simply

By Safdar Ali — frontend engineer, Bengaluru

I'm Safdar Ali, a frontend engineer in Bengaluru. "Promises vs async await" is not a rivalry — async/await is syntax sugar on top of Promises. You still need both mental models for React, Next.js Server Components, and every fetch call. This guide uses BEFORE/AFTER pairs from production code, not toy timers.

Async JavaScript is why frontend engineers touch backends without changing jobs — fetch, auth, file uploads, AI streams. Promises formalised callbacks; async/await made them readable. Search "promises vs async await" and forums still debate syntax preference. In production the debate is error handling and parallelism. Get those right and either syntax works; get them wrong and both fail silently on slow networks in India.

Mental model — pending, fulfilled, rejected

Every Promise is in one of three states. await only pauses inside async functions until fulfillment or rejection. The event loop keeps running other tasks — your loading spinner can animate while fetch waits.

Same async operation — Promise chain vs async/await

type User = { id: string; name: string };

// Promise style
function loadUserPromise(id: string): Promise<User> {
  return fetch("/api/users/" + id)
    .then((res) => {
      if (!res.ok) throw new Error("HTTP " + res.status);
      return res.json() as Promise<User>;
    });
}

// async/await style — identical behavior
async function loadUserAsync(id: string): Promise<User> {
  const res = await fetch("/api/users/" + id);
  if (!res.ok) throw new Error("HTTP " + res.status);
  return res.json() as Promise<User>;
}

Under the hood, async function always returns a Promise. await pauses that function until the Promise settles — it does not block the main thread.

fetch returns a Promise immediately — the HTTP response arrives later. await does not make fetch synchronous; it only pauses your async function until the Promise settles. Other UI keeps updating because the browser event loop is free. That distinction is what interviewers test when they ask about blocking vs non-blocking I/O.

BEFORE / AFTER — escaping callback hell

// BEFORE — nested callbacks (hard to read, easy to miss errors)
getUser(id, (err, user) => {
  if (err) return handle(err);
  getOrders(user.id, (err2, orders) => {
    if (err2) return handle(err2);
    render(user, orders);
  });
});

// AFTER — async/await linear flow
async function showDashboard(id: string) {
  try {
    const user = await loadUserAsync(id);
    const orders = await loadOrders(user.id);
    render(user, orders);
  } catch (err) {
    handle(err);
  }
}

Error handling — try/catch vs .catch()

StyleWhen I use it
try/catch around awaitMost async functions
.catch() on chainFire-and-forget analytics
Promise.allSettledPartial failures OK
// BEFORE — forgot await, try/catch never runs on rejection
async function bad() {
  try {
    const data = fetch("/api/data"); // missing await — bug
    return data;
  } catch (e) {
    console.error(e);
  }
}

// AFTER
async function good() {
  try {
    const res = await fetch("/api/data");
    if (!res.ok) throw new Error("Failed");
    return await res.json();
  } catch (e) {
    console.error(e);
    throw e; // rethrow for UI error boundary
  }
}

Parallel fetch — Promise.all vs sequential await

Sequential await is slow when requests are independent. Dashboard needs user + orders + notifications — run together.

// BEFORE — 3 round trips in series (~900ms total on 300ms each)
async function loadSlow() {
  const user = await fetchUser();
  const orders = await fetchOrders();
  const notes = await fetchNotifications();
  return { user, orders, notes };
}

// AFTER — parallel (~300ms + overhead)
async function loadFast() {
  const [user, orders, notes] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchNotifications(),
  ]);
  return { user, orders, notes };
}
// One failure should not kill all — allSettled
const results = await Promise.allSettled([
  fetchUser(),
  fetchOrders(),
]);
const user = results[0].status === "fulfilled" ? results[0].value : null;

Promise.race is useful for timeouts — reject if fetch exceeds eight seconds on flaky mobile data. Document timeout values in API client modules so the whole app behaves consistently. Sequential await is correct when order matters: create user, then create profile with user id — parallelising those two calls would be a bug.

Next.js Server Components — async by default

// app/dashboard/page.tsx
type Stats = { users: number; revenue: number };

async function getStats(): Promise<Stats> {
  const res = await fetch("https://api.example.com/stats", {
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error("Stats unavailable");
  return res.json();
}

export default async function DashboardPage() {
  const stats = await getStats();
  return <StatsPanel stats={stats} />;
}

Server Components are async functions — no useEffect fetch. See RSC vs client components.

Client pitfalls — useEffect and race conditions

// BEFORE — no abort, stale response overwrites new search
useEffect(() => {
  fetch("/api/search?q=" + query).then((r) => r.json()).then(setResults);
}, [query]);

// AFTER — AbortController
useEffect(() => {
  const ctrl = new AbortController();
  (async () => {
    try {
      const res = await fetch("/api/search?q=" + query, { signal: ctrl.signal });
      const data = await res.json();
      setResults(data);
    } catch (e) {
      if ((e as Error).name !== "AbortError") console.error(e);
    }
  })();
  return () => ctrl.abort();
}, [query]);

Promises vs async/await — quick comparison

TopicPromises (.then)async/await
ReadabilityChains indent rightLinear top-to-bottom
Error handling.catch() at endtry/catch around await
ParallelismPromise.all([...])await Promise.all([...])
Return typeAlways Promiseasync fn returns Promise
DebuggingStack in .thenStack at await line

When I keep raw Promises

Utility libraries returning Promises, combining with Promise.race for timeouts, or .then() in non-async callbacks. async/await is for readable control flow; Promises are the underlying contract.

Creating Promises — wrap legacy callbacks once

function readFileAsync(path: string): Promise<string> {
  return new Promise((resolve, reject) => {
    readFile(path, "utf8", (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

// Prefer promisify utilities in Node; in browser wrap once at module boundary

Async iterators — streams beyond one JSON blob

async function* lineChunks(stream: ReadableStream<Uint8Array>) {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";
    for (const line of lines) yield line;
  }
}

Large CSV exports and AI streaming responses use async iteration. Promises handle single results; iterators handle sequences. Both compose with await in for-await-of loops.

Unhandled rejections — the production alert you want

// Node / edge — log unhandled rejections in API routes
process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
});

// Always return Response from route handlers — never floating promise
export async function GET() {
  try {
    const data = await loadData();
    return Response.json(data);
  } catch (e) {
    return Response.json({ error: "Server error" }, { status: 500 });
  }
}

Floating promises in server actions and API routes caused silent 500s on a client project until we wrapped every async entry point. Treat await like a return statement — every path must settle.

Practice pattern

Rewrite one sequential useEffect fetch as Promise.all. Add try/catch and rethrow. Measure network tab — the win is immediate on slow Indian mobile networks.

Async literacy is the bridge between UI work and API design. When you read backend OpenAPI docs, you already think in Promises — negotiating timeout and retry policies with your team gets easier.

The syntax debate ends when you debug: stack traces from async/await point at your await line; Promise chains point at the nearest .then. Pick whichever makes the failure obvious to your future self.

Server Actions in Next.js return Promises to the client when you call them from forms — same mental model, different wire format. Always handle rejection in UI with error boundaries or toast messages; unhandled rejections in the browser console are user-facing failures you never measured.

Next: JavaScript array methods. Performance: web performance checklist.

Async patterns in React 19 and Next.js 15 still bottom out on Promises — Suspense boundaries await async children, error boundaries catch rejections. When you wrap a component in Suspense, you are telling React a Promise will resolve with UI. The syntax changed; the contract did not. Learn Promise.all before learning Server Actions — the same parallelism rules apply server-side.

Testing async code: use async test functions in Vitest, await expect(loadUser()).resolves.toMatchObject(...). Mock fetch at module level for API units; integration tests hit MSW handlers. Flaky tests often come from missing await or shared mutable state between tests — isolate with beforeEach resets.

Indian mobile networks add packet loss — always set fetch timeouts and show retry UI. Users blame your app, not Jio. Log failed requests with correlation ids in API routes so support can trace one user complaint to one server log line. Async without observability is debugging with eyes closed.

Dynamic import() returns a Promise — lazy routes in React and Next.js use it under the hood. await import() in event handlers loads code on demand; do not confuse with static import at top of file. Error boundaries catch render failures; import().catch handles load failures for chunks — handle both in production apps with friendly fallback UI.

Microtask queue education: await schedules continuations as microtasks; setTimeout is macrotask. Ordering bugs appear when mixing them in tests — flushPromises helpers in test utils exist for a reason. Read MDN event loop once; every async interview question becomes easier.

Promises vs async/await is not a style war — it is one runtime model. Write async/await in application code, read Promise chains in library source, debug both in stack traces. Ship one refactor this sprint: parallelise three serial fetches and measure waterfall in Network tab. The milliseconds you save are user trust earned.

Copy these BEFORE/AFTER blocks into your team wiki — replace URLs with internal APIs. New hires in India often learn async from YouTube shorts that skip error handling; this article is the correction layer before they touch production checkout flows.

finally blocks run after try/catch regardless of success — use for cleanup (revoke object URLs, set loading false). Do not return values from finally that override try return — confusing control flow. Keep finally boring: flags and cleanup only.

Async generators and for await...of handle streams of chunks — payment webhooks, log tailing, SSE. Promises return one value; iterators return many. Learn for await after mastering Promise.all — not before.

Top-level await in ES modules runs at import time — powerful in Node scripts, dangerous in client bundles that block parse. Next.js restricts patterns; follow framework docs. When in doubt, await inside async functions you call from entry points you control.

Save this article for code review — when you see .then().then().then(), suggest async/await refactor in a follow-up PR. When you see missing await, block merge. Async bugs are P0 in payment flows; style issues can wait, correctness cannot.

Promises vs async await is the same language at different readability levels — master both, ship fewer bugs, and explain your Next.js data layer in interviews without hand-waving. That is the whole goal of this guide from Bengaluru production work, not syntax trivia. Re-read the parallel fetch BEFORE/AFTER before your next dashboard page ships.

Promises vs async await confusion usually hides one bug: missing await, serial fetch, or swallowed rejection. Fix those three patterns and most async incidents disappear from your on-call rotation — more valuable than debating syntax preference in Slack threads that never ship code.

Open Network tab, find three serial fetches, parallelise with Promise.all, measure — that ten-minute exercise is worth more than rereading this paragraph. Promises vs async await only matters when milliseconds and error paths reach real users on real networks in India and everywhere else your app ships.

Server Actions return Promises to the client when invoked from forms — handle pending and error UI explicitly. Route handlers must await every async call before returning Response — floating promises caused silent failures on a fintech project I debugged in Bengaluru until we added integration tests that assert status codes. Async literacy is not optional for full-stack frontend roles in 2026; it is the baseline.

Copy the BEFORE/AFTER blocks into your codebase as comments above legacy fetch code — future refactors will thank you. Delete the comments after refactor ships. Promises vs async await is maintenance work, not a one-time read ever.

If this helped you

I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:

More guides on safdarali.in — same author, production-focused.

"Talk is cheap. Show me the code."