JS / TS

Async/Await vs Promises: Cleaning Up Deeply Nested Code

3 min read

Every JavaScript developer eventually writes the pyramid: a .then() inside a .then() inside a .then(), with error handling bolted somewhere it doesn't quite cover. Then someone shows them async/await and the pyramid flattens into something readable. This post is that transformation — plus the two things people consistently get wrong after making it.

The 3-level-deep problem

A realistic sequence: fetch a user, then their active project, then that project's tasks:

function loadDashboard(userId) {
  return fetchUser(userId).then((user) => {
    return fetchProject(user.activeProjectId).then((project) => {
      return fetchTasks(project.id).then((tasks) => {
        return { user, project, tasks };
      });
    });
  });
}

Notice why it nests: each step needs a variable from an earlier step (user at the end), so you can't flatten the chain without losing access to it. The indentation is annoying; the scoping problem is the real disease.

What async/await actually is

Not a replacement for Promises — syntax over Promises. An async function always returns a Promise; await pauses the function (without blocking the thread — the event loop carries on) until a Promise settles, then hands you its value as a plain variable. Same machinery, sequential syntax:

async function loadDashboard(userId) {
  const user = await fetchUser(userId);
  const project = await fetchProject(user.activeProjectId);
  const tasks = await fetchTasks(project.id);
  return { user, project, tasks };
}

The scoping problem dissolves: every intermediate value is a normal const, available for the rest of the function. Error handling becomes ordinary try/catch covering the whole sequence. Debugging improves too — step-through debuggers and stack traces treat this like the sequential code it reads as.

Because it's still Promises underneath, the two styles interoperate freely: you can await any Promise-returning library, and callers can .then() your async function. Migration can be incremental.

Where you still need Promise.all

Here's the subtle downgrade hiding in await: it's sequential. If steps don't depend on each other, awaiting them one by one serializes work that could run in parallel:

// ❌ Sequential: ~600ms if each takes ~200ms
const profile = await fetchProfile(id);
const notifications = await fetchNotifications(id);
const settings = await fetchSettings(id);
 
// ✅ Parallel: ~200ms — start all three, then wait once
const [profile, notifications, settings] = await Promise.all([
  fetchProfile(id),
  fetchNotifications(id),
  fetchSettings(id),
]);

The rule: await expresses dependency; Promise.all expresses independence. The dashboard example genuinely is dependent (each call needs the previous result), so sequential is correct there. Three unrelated fetches are not. Also worth knowing: Promise.allSettled when you want all results even if some fail, and Promise.race for timeouts.

Common async/await mistakes

  • Forgetting errors entirely. A rejected Promise in an async function without try/catch (or a .catch() upstream) becomes an unhandled rejection. Wrap the await sequence, and decide per call whether a failure is fatal or recoverable.
  • Accidental serialization. The Promise.all case above — the most common performance bug I find in code review, invisible because the code works, just slower than it should be.
  • await in loops. for (const id of ids) await fetchItem(id) fires requests one at a time. Often you want await Promise.all(ids.map(fetchItem)) — but mind rate limits; sometimes sequential is the polite choice. Make it a decision, not an accident.
  • Mixing styles half-heartedly. await fn().then(...) works but reads like a sentence that switches language halfway. Pick the async/await register and stay in it.

The takeaway

Use async/await as your default register — it reads sequentially, scopes naturally, and error-handles conventionally. Keep Promises in your active vocabulary for what they express better: parallelism (Promise.all), aggregation (allSettled), and racing (race). The developers who struggle here are the ones who learned the syntax as a replacement; the ones who thrive learned it as a view over the same Promise machinery — because then every "weird" behavior has an explanation.

Found this useful?

Share

I post shorter takes on javascript & typescript and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.