Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /5 async/await Mistakes That Slow Your JavaScript Code
javascript20 min read

5 async/await Mistakes That Slow Your JavaScript Code

Sequential awaits, await in forEach, missing Promise.all: these 5 async/await mistakes silently slow your JavaScript. Here's how to spot and fix each one.

Zeeshan Tofiq
Zeeshan Tofiq
May 30, 2026
On this page

On this page

  • Sequential Awaits on Independent Tasks
  • await Inside forEach
  • Promise.all Without Failure Handling
  • The Silent Missing await
  • await on a map That Returns Promises
  • When Sequential Is the Right Choice
  • Quick Decision Guide
  • Frequently Asked Questions

If you've ever wondered why your dashboard takes 3 seconds to load when your API calls each only take 1 second, this post is for you.

async/await makes asynchronous code look clean. It also makes it very easy to accidentally run things in sequence that should run in parallel. Here are five mistakes that show up in real production code, with before/after examples and exactly how to fix each one.

  1. 1

    Sequential Awaits When Tasks Are Independent

    The most expensive performance mistake in async JavaScript. When tasks don't depend on each other, awaiting them one-by-one means 3 one-second calls take 3 seconds instead of 1.

    javascript
    // ❌ Sequential (3 requests × 1 second = 3 seconds total)
    const user  = await fetchUser(userId);
    const posts = await fetchPosts(userId);
    const stats = await fetchAnalytics(userId);
    
    // ✅ Parallel with Promise.all (~1 second, slowest request wins)
    const [user, posts, stats] = await Promise.all([
      fetchUser(userId),
      fetchPosts(userId),
      fetchAnalytics(userId),
    ]);
    • Promise.all kicks off all three requests simultaneously and waits for all of them to finish.
    • The total time equals the duration of the slowest request, not the sum of all requests.
    • The rule: if Task B doesn't need Task A's result, don't await Task A first.

    💡 Tip

    Check every async function for sequential await calls on independent tasks. This single change can cut API response times by 2–3x on data-heavy pages.

  2. 2

    await Inside forEach

    forEach was not designed to understand Promises. When you mark its callback async, it starts each promise but doesn't wait: execution continues immediately.

    javascript
    // ❌ await inside forEach (fire-and-forget)
    orderIds.forEach(async (id) => {
      await processOrder(id); // not waited for
    });
    console.log('Done!'); // runs immediately, nothing has processed yet
    
    // ✅ Parallel: Promise.all + map
    await Promise.all(orderIds.map(id => processOrder(id)));
    console.log('Done!'); // waits for all orders
    
    // ✅ Sequential: for...of with await
    for (const id of orderIds) {
      await processOrder(id);
    }
    • forEach calls each callback and moves on, ignoring any returned Promise.
    • Use for...of with await when tasks must run in order.
    • Use Promise.all + map when tasks can run in parallel.

    🚫 Danger

    This produces no error or warning. The async callbacks run as fire-and-forget in the background. You have no way to know when they finish or if any of them fail.

  3. 3

    Promise.all Without Handling Partial Failures

    Promise.all is fast but unforgiving: if one promise rejects, the entire call rejects and you lose results from every promise that succeeded.

    javascript
    // ❌ One failure kills all three results
    const [user, posts, stats] = await Promise.all([
      fetchUser(userId),
      fetchPosts(userId),
      fetchAnalytics(userId), // throws 500 → you get nothing
    ]);
    
    // ✅ Promise.allSettled (partial results on failure)
    const results = await Promise.allSettled([
      fetchUser(userId),
      fetchPosts(userId),
      fetchAnalytics(userId),
    ]);
    const user  = results[0].status === 'fulfilled' ? results[0].value : null;
    const posts = results[1].status === 'fulfilled' ? results[1].value : [];
    • Use Promise.all when you need every result and a partial failure genuinely means you can't continue.
    • Use Promise.allSettled when partial data is better than a full error: most dashboard UIs fall here.

    ℹ Info

    Promise.allSettled always resolves, never rejects. Each result object has status: 'fulfilled' or status: 'rejected' so you handle each outcome independently.

  4. 4

    The Silent Missing await

    The most dangerous mistake: forgetting await on an async function produces no error. The operation runs fire-and-forget and execution continues immediately.

    javascript
    // ❌ Missing await (validation runs but isn't waited for)
    async function createPost(data) {
      validatePost(data);          // fires and is forgotten
      return await savePost(data); // runs before validation finishes
    }
    
    // ✅ With await (validation must complete before saving)
    async function createPost(data) {
      await validatePost(data);
      return await savePost(data);
    }
    • If validatePost throws, the rejection is silently swallowed and savePost runs anyway.
    • Tests often miss this because they only check the happy path: the bug surfaces in production.
    • TypeScript with @typescript-eslint/no-floating-promises catches missing await calls statically.

    Enable the ESLint rule that catches this automatically:

    javascript — eslint.config.mjs
    import tseslint from 'typescript-eslint';
    
    export default tseslint.config({
      rules: {
        '@typescript-eslint/no-floating-promises': 'error',
      },
    });

    ⚠ Warning

    Also run Node.js with --unhandled-rejections=strict during development. It crashes the process on silent promise rejections instead of ignoring them: a loud failure in dev beats a silent one in production.

  5. 5

    Awaiting a map That Returns Promises

    When you use async inside a map callback, map returns an array of Promises, not resolved values. Awaiting the array itself resolves immediately, since it's not a Promise. Related to Mistake 2 (forEach) but with a different cause.

    javascript
    // ❌ await on the array (resolves immediately with [Promise, Promise, Promise])
    const results = await items.map(async (item) => fetchData(item));
    // results is [Promise {}, Promise {}, Promise {}], not actual values
    
    // ✅ Wrap with Promise.all (resolves all promises in the array)
    const results = await Promise.all(
      items.map(async (item) => fetchData(item))
    );
    // results is now the actual fetched values
    • map returns a new array synchronously: it does not wait for async callbacks.
    • The outer await resolves the array synchronously (arrays are not Promises).
    • The fix is always await Promise.all(items.map(async item => fn(item))).

    💡 Tip

    Mental model: map with an async callback gives you an array of Promises. Promise.all turns that array into a single Promise resolving with the array of values.

  6. 6

    When Sequential Is the Right Choice

    Not every situation calls for parallel execution. Sequential await is the correct choice in specific scenarios:

    • Task B depends on Task A's result: fetch a user, then fetch their orders using the user ID.
    • You are writing to a database where concurrent writes on the same record create race conditions.
    • You are processing a queue where order matters and each item must complete before the next begins.
    javascript
    // ✅ Sequential (fetchOrders needs userId from the user object)
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id); // depends on user result
    javascript
    // ✅ Sequential queue (order matters, concurrent writes risk race conditions)
    for (const item of queue) {
      await processItem(item);
    }

    ℹ Info

    The question to ask before reaching for Promise.all: does this task need the previous result to start? If yes, await sequentially. If no, run in parallel.

Quick Decision Guide

Pick the right async pattern based on whether tasks depend on each other's results:

SituationPattern to use
Independent tasks, need all resultsawait Promise.all([a(), b(), c()])
Independent tasks, partial failure OKawait Promise.allSettled([a(), b()])
Each result feeds the next taskSequential await or for...of with await
Array of items processed in parallelawait Promise.all(items.map(async i => fn(i)))
Array processed one at a timefor (const item of items) { await fn(item); }

What await Actually Does

Every mistake in this post comes from the same root cause: await looks like it pauses the program, but it only pauses one function. Getting the mental model right makes the bugs obvious instead of mysterious.

When the engine reaches an await, it suspends the current async function, wraps the awaited value in a resolved Promise if it is not one already, and returns control to the caller. The rest of the function body is scheduled as a microtask that runs once the awaited Promise settles.

That means an async function returns to its caller the moment it hits the first await, not when it finishes. Everything after the await runs later, on a separate turn of the event loop.

javascript
async function demo() {
  console.log('1: runs synchronously');
  const value = await 42;   // not a Promise, but await still yields
  console.log('3: resumed in a microtask', value);
}

demo();
console.log('2: the caller keeps running');

// Output order: 1, 2, 3

Notice that await 42 still defers the rest of the function even though 42 is not a Promise. await always yields at least one microtask tick. This is why sprinkling await on synchronous values is never free, and why ordering bugs appear in code that looks perfectly linear.

The second consequence is the important one for performance. A Promise starts doing its work the moment it is created, not the moment you await it. Calling fetchUser(id) fires the request immediately. The await only decides when you collect the result.

💡 Tip

If the timing still feels abstract, step through it visually with the async visualizer tool. Watching the call stack, microtask queue, and macrotask queue drain in order makes the ordering rules concrete.

This create-now, collect-later split is the single most useful thing to internalise. It is also the trick behind the fastest fix for sequential code: start every Promise first, then await them together.

javascript
// Both requests are already in flight before the first await runs
const userPromise  = fetchUser(userId);
const postsPromise = fetchPosts(userId);

const user  = await userPromise;
const posts = await postsPromise;
// Total time = the slower of the two, not the sum

This pattern is equivalent to Promise.all for timing, and some teams prefer it because each variable keeps its own name and type. The catch is error handling, which is covered further down: if userPromise rejects before you reach await postsPromise, the second rejection can go unhandled.

await in Loops vs Promise.all

Mistake 1 showed three hardcoded calls. The version that actually costs teams money is the same pattern hidden inside a loop, where the penalty scales with the size of the array.

A for...of loop with an await inside processes strictly one item at a time. Ten items at 200ms each take two full seconds, and nobody notices in development because the seed database has three rows.

javascript
const ids = [1, 2, 3, 4, 5];
const fetchOne = (id) =>
  new Promise((resolve) => setTimeout(() => resolve(id * 2), 200));

// Sequential: 5 x 200ms
console.time('sequential');
const sequential = [];
for (const id of ids) {
  sequential.push(await fetchOne(id));
}
console.timeEnd('sequential');   // sequential: ~1002ms

// Parallel: bounded by the slowest single call
console.time('parallel');
const parallel = await Promise.all(ids.map(fetchOne));
console.timeEnd('parallel');     // parallel: ~204ms

The gap widens linearly with the array length. Here is what the same 200ms operation costs across common batch sizes, comparing a sequential loop, unbounded Promise.all, and a pool that caps concurrency at 10:

Wall-clock time for N operations that each take 200ms
ItemsSequential loopPromise.allPool of 10
5~1.0s~0.2s~0.2s
25~5.0s~0.2s~0.6s
100~20.0s~0.2s (100 sockets open)~2.0s
1,000~200.0sLikely rate limited or out of memory~20.0s

The last row is the reason unbounded Promise.all is not always the answer. Firing a thousand simultaneous requests will trip API rate limits, exhaust your database connection pool, or hit the operating system file descriptor limit long before it gives you a thousand-fold speedup.

There is also a subtle memory cost. Promise.all holds every resolved value in memory until the last one settles. Mapping a million database rows into a single Promise.all keeps all million results alive at once.

⚠ Warning

Promise.all over an array whose length you do not control is a denial-of-service vector against your own infrastructure. If the array comes from user input, a database query, or a paginated API, cap the concurrency.

Limiting Concurrency Without a Library

The middle ground between one-at-a-time and all-at-once is a worker pool: run a fixed number of tasks concurrently, and start the next task as soon as a slot frees up. You do not need a dependency for this.

The implementation below keeps a shared cursor into the input array and spawns limit workers that each pull the next index until the array is exhausted. Results land in their original positions, so ordering is preserved.

javascript — mapWithConcurrency.js
export async function mapWithConcurrency(items, limit, fn) {
  const results = new Array(items.length);
  let cursor = 0;

  async function worker() {
    while (cursor < items.length) {
      const index = cursor++;
      results[index] = await fn(items[index], index);
    }
  }

  const size = Math.min(limit, items.length);
  await Promise.all(Array.from({ length: size }, worker));
  return results;
}

// At most 10 requests in flight, no matter how long urls is
const bodies = await mapWithConcurrency(urls, 10, (url) =>
  fetch(url).then((res) => res.text())
);

Note that cursor++ is safe here despite looking like a shared-state race. JavaScript is single-threaded, and the increment happens synchronously between awaits, so no two workers can ever claim the same index.

Choosing the limit is an empirical exercise, not a formula. Start with the constraint you are protecting: your database pool size, the API's documented rate limit, or the number of sockets your host allows.

Practical concurrency limits by workload type
WorkloadReasonable starting limitConstraint to watch
Postgres queriesPool size minus 2Connection pool exhaustion
Third-party REST APIDocumented rate limit per second429 responses and IP bans
S3 or blob uploads10 to 20Upstream bandwidth
Local filesystem readsNumber of CPU coresEvent loop and disk queue depth
Internal microservice20 to 50Downstream service capacity

💡 Tip

Before adding concurrency, check whether the work needs to happen at all. A well-placed cache removes far more latency than parallelism ever will. The caching strategies guide covers which pattern fits which read profile.

Error Handling in async Functions

Async error handling looks familiar because it reuses try/catch, and that familiarity is exactly what hides the gaps. A try block only catches rejections from Promises you actually await inside it.

What try/catch Around await Actually Covers

The classic gap is returning a Promise instead of awaiting it. The function still resolves to the right value on the happy path, so tests pass, but the catch block is dead code.

javascript
// Broken: the rejection escapes to the caller, catch never runs
async function loadProfile(userId) {
  try {
    return fetchUser(userId);        // returned, not awaited
  } catch (error) {
    return null;                     // unreachable
  }
}

// Correct: await inside the try so the rejection is caught here
async function loadProfile(userId) {
  try {
    return await fetchUser(userId);
  } catch (error) {
    console.error('Profile load failed', error);
    return null;
  }
}

This is why return await inside a try block is correct and not redundant. Outside a try, return await promise and return promise behave the same, which is why some older lint configs flagged it. Inside a try, dropping the await silently disables your error handling.

The same gap opens whenever a Promise is created inside a callback the try block does not await. A rejection thrown from a setTimeout callback, an event listener, or a forEach callback never reaches the surrounding try, because that code runs on a later tick with an empty call stack.

javascript
// The try block has already exited by the time the callback runs
try {
  setTimeout(() => {
    throw new Error('never caught here');
  }, 0);
} catch (error) {
  // unreachable
}

Where You Put .catch Changes What It Catches

A .catch() handler only covers the part of the chain that appears before it. Attaching it directly to the fetching call leaves any transformation that follows unprotected.

javascript
// Covers fetchUser only. If u is null, the .then callback throws
// and that error is NOT handled by this catch.
const name = await fetchUser(id)
  .catch(() => null)
  .then((u) => u.profile.name);

// Covers the whole chain, including the transformation
const name = await fetchUser(id)
  .then((u) => u.profile.name)
  .catch(() => 'Anonymous');

The rule is simple: put .catch() last, after every .then() whose failure you want to handle. If you need different recovery for different stages, use more than one .catch() and place each one immediately after the stage it protects.

Unhandled Rejections in Node.js

Since Node.js 15, an unhandled Promise rejection is treated as an uncaught exception and terminates the process by default. That is a deliberate improvement over the old behaviour, which printed a warning and carried on with corrupted state.

The trap is creating several Promises up front and awaiting them one by one. If the first one rejects, the function unwinds and the later awaits never execute, leaving those Promises with no handler attached.

javascript
// Trap: if a rejects, the await on b never runs and b's
// rejection is unhandled, which can crash the process
const a = riskyCall('a');
const b = riskyCall('b');
const resultA = await a;
const resultB = await b;

// Fix: hand every Promise to a combinator immediately
const [resultA, resultB] = await Promise.all([
  riskyCall('a'),
  riskyCall('b'),
]);

// Or keep both outcomes regardless of failure
const settled = await Promise.allSettled([
  riskyCall('a'),
  riskyCall('b'),
]);

Promise.all attaches a handler to every Promise in the array the moment it is called, so a second, later rejection is absorbed rather than left dangling. It still rejects with the first failure only.

In long-running services, register a process-level handler so unhandled rejections are logged with context before the process exits. Log first, then exit: never swallow the error and keep serving traffic on unknown state.

javascript
process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'Unhandled promise rejection');
  process.exit(1);   // let the orchestrator restart a clean process
});

ℹ Info

In browsers the equivalent is window.addEventListener('unhandledrejection', handler). Wire it into your error reporting so client-side floating Promises show up in the same dashboard as thrown exceptions.

Error propagation through async boundaries is a favourite interview topic precisely because it separates people who have shipped async code from people who have only read about it. The Node.js interview questions guide works through several variations of this exact scenario.

Race Conditions and Cancellation with AbortController

Parallelism introduces a class of bug that sequential code cannot have: two in-flight operations finishing in an order you did not expect. The canonical example is a search box that fires a request per keystroke.

Type re, then react. If the request for re is slower than the request for react, the stale results arrive last and overwrite the correct ones. The code is perfectly valid, and the UI is wrong.

javascript
// Race condition: a slower earlier request overwrites newer results
async function search(query) {
  const data = await fetchResults(query);
  render(data);   // whichever response lands last wins
}

The lightweight fix is a request sequence number. Capture the current token before awaiting, then discard the response if a newer request has started in the meantime.

javascript
let latestRequestId = 0;

async function search(query) {
  const requestId = ++latestRequestId;
  const data = await fetchResults(query);
  if (requestId !== latestRequestId) return;   // superseded, drop it
  render(data);
}

That fixes the display, but the abandoned requests still consume bandwidth and server capacity. AbortController cancels them properly: call abort() on the previous controller before starting a new request, and pass the signal into fetch.

javascript
let controller = null;

async function search(query) {
  controller?.abort();                 // cancel the previous request
  controller = new AbortController();

  try {
    const res = await fetch(
      `/api/search?q=${encodeURIComponent(query)}`,
      { signal: controller.signal }
    );
    return await res.json();
  } catch (error) {
    if (error.name === 'AbortError') return null;   // expected, ignore
    throw error;
  }
}

An aborted fetch rejects with a DOMException whose name is AbortError. Always check for it explicitly. Reporting cancellations as failures is how error dashboards fill up with noise that nobody can act on.

The same signal mechanism gives you timeouts without hand-rolling a Promise.race. AbortSignal.timeout(ms) returns a signal that aborts itself, and AbortSignal.any combines several signals into one.

javascript
// Abort automatically after 5 seconds
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });

// Abort on timeout OR when the user cancels
const signal = AbortSignal.any([
  AbortSignal.timeout(5_000),
  userController.signal,
]);
const res = await fetch(url, { signal });

ℹ Info

AbortSignal is not limited to fetch. Node.js accepts a signal option in fs/promises, child_process, readline, and events.once. Any long-running API you write should accept one too.

Async Constructors and Top-Level await

Constructors cannot be async and cannot contain await. A constructor must return the instance synchronously, and an async function always returns a Promise, so the two are fundamentally incompatible.

Developers work around this by assigning a Promise to a field, which pushes the problem onto every caller: now every method has to remember to await this.ready first, and forgetting it is exactly the silent missing-await bug from Mistake 4.

javascript
// Invalid: SyntaxError, await is not allowed in a constructor
class Database {
  constructor(url) {
    this.connection = await connect(url);
  }
}

// Fragile: callers must remember to await this.ready
class Database {
  constructor(url) {
    this.ready = connect(url);   // easy to forget downstream
  }
}

The clean solution is a static async factory. The constructor stays synchronous and takes an already-resolved dependency, while a static method does the awaiting and hands back a fully initialised instance.

javascript — database.js
export class Database {
  #connection;

  // Private in spirit: callers should use Database.open()
  constructor(connection) {
    this.#connection = connection;
  }

  static async open(url) {
    const connection = await connect(url);
    return new Database(connection);
  }

  query(sql, params) {
    return this.#connection.query(sql, params);   // no readiness check needed
  }
}

const db = await Database.open(process.env.DATABASE_URL);

Every instance that exists is guaranteed usable, so no method needs a defensive readiness check. This is the pattern most database drivers and SDK clients converged on.

That final line uses top-level await, which works in ES modules ("type": "module" in package.json, or a .mjs file) and in TypeScript compiled to an ES2022 module target. It does not work in CommonJS, where the equivalent is an async IIFE or a lazily awaited singleton Promise.

javascript
// ES module: top-level await is allowed
const config = await loadConfig();
export default config;

// CommonJS: export a Promise and await it at the use site
const configPromise = loadConfig();
module.exports = { configPromise };

⚠ Warning

Top-level await blocks the module graph. Every module that imports yours waits for that Promise before it starts executing. Keep it to fast startup work such as reading config, never a network call that can hang.

Async initialisation also interacts with framework rendering models. In React, an async component boundary suspends and the scheduler decides when to resume, which is a different mechanism from the microtask queue described earlier. The React Fiber architecture explainer covers how that scheduling actually works.

If your async work involves timers, deadlines, or scheduling across time zones, prefer the modern date API over hand-rolled arithmetic on millisecond offsets. The Temporal API guide covers the replacement for Date in Node.js.

Async Code Review Checklist

Run through this list on any pull request that touches async code. Each item maps to a specific bug covered above, and most take seconds to verify.

  • Every await on an independent task has been checked against Promise.all
  • No async callback is passed to forEach, filter, some, or every
  • Every map with an async callback is wrapped in Promise.all or Promise.allSettled
  • Promise.all over a variable-length array has a concurrency cap
  • Every return inside a try block that returns a Promise uses return await
  • .catch() is placed after the last .then() it needs to cover
  • Promises are handed to a combinator immediately, not created and awaited one by one
  • @typescript-eslint/no-floating-promises is enabled and passing
  • A process.on('unhandledRejection') handler exists in every long-running service
  • Requests that can be superseded pass an AbortSignal or carry a sequence token
  • AbortError is filtered out before errors are reported to monitoring
  • No class stores an unawaited Promise that callers must remember to await

💡 Tip

Turn the first four items into lint rules rather than review habits. no-floating-promises, no-misused-promises, and await-thenable from typescript-eslint catch the majority of these mechanically.

Frequently Asked Questions

Does Promise.all actually run tasks in parallel?
  • I/O-bound work (network requests, database queries): yes. Operations are initiated simultaneously and run concurrently at the network/OS level.
  • CPU-bound computation: no. JavaScript is single-threaded, so Promise.all can't help here.
  • Most async bottlenecks in web apps are I/O, not CPU, so Promise.all gives real speed gains in practice.
Why doesn't await inside forEach work?
  • forEach calls each async callback and immediately discards the returned Promise.
  • It was designed before async/await existed and has no mechanism to wait for Promises.
  • Execution continues synchronously after the forEach call, before any of the async work is done.
javascript
// forEach (fire-and-forget, broken)
items.forEach(async (item) => await fn(item));

// for...of (sequential, correct)
for (const item of items) { await fn(item); }

// Promise.all + map (parallel, correct)
await Promise.all(items.map(item => fn(item)));
When should I use Promise.all vs Promise.allSettled?
Promise.allPromise.allSettled
Rejects if any fails?Yes (entire call rejects)No (always resolves)
Use whenEvery result is requiredPartial success is acceptable
Best forCritical data where missing = broken UIDashboards that show as much as they can
How do I catch missing await bugs?
  • `@typescript-eslint/no-floating-promises`: ESLint rule that flags async function calls without await. See Step 4 for the config.
  • `--unhandled-rejections=strict`: Node.js flag that crashes the process on silent rejections during development.
  • Tests: write tests that intentionally trigger the async path with failures to surface missing awaits.
When would I use Promise.race or Promise.any?
  • `Promise.race`: resolves or rejects as soon as the first promise settles. Use for timeouts: race a request against a timer.
  • `Promise.any`: resolves as soon as the first promise fulfills. Use for redundant requests where you want the fastest success.
  • Both are advanced patterns for specific use cases, not general alternatives to Promise.all.
Why does my async function return Promise { <pending> } instead of the value?

Because that is the only thing an async function can return. The return statement inside the body settles the Promise the function already handed back; it does not pass the value to the caller synchronously. When you log the call without awaiting it, the work has not finished yet, so you see the wrapper in its pending state.

javascript
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

const user = getUser(1);
console.log(user);        // Promise { <pending> }

const real = await getUser(1);
console.log(real.name);   // 'Ada'

If you cannot use await at that point (a non-async callback, or a CommonJS entry file), use .then() or wrap the work in an async IIFE. There is no synchronous escape hatch: the value genuinely does not exist yet at the moment the call returns, so no API can unwrap it for you.

How do I use async/await inside a React useEffect?

Never make the effect callback itself async. useEffect expects either nothing or a cleanup function back, and an async function returns a Promise, so React warns and your cleanup never runs. Declare an async function inside the effect and call it.

tsx
useEffect(() => {
  const controller = new AbortController();

  async function load() {
    try {
      const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
      setUser(await res.json());
    } catch (err) {
      if (err.name !== 'AbortError') setError(err);
    }
  }

  load();
  return () => controller.abort();
}, [id]);

The cleanup aborts the in-flight request whenever id changes or the component unmounts. That kills the classic bug where a slow response for an old id resolves last and overwrites the state belonging to the current one.

Filter AbortError out of your error handling. It is the expected outcome of a cancellation, not a failure, and showing it to the user or reporting it to monitoring is noise.

Most async performance bugs aren't complicated: they're await calls that should be Promise.all, and forEach loops that should be for...of.

Make a habit of asking one question before every await: does this task need the previous result? That question catches most of the mistakes in this post.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

nextjs

How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)

Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.

May 30, 2026·15 min read
databases

Drizzle ORM Migrations: A Practical drizzle-kit Guide

Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.

May 30, 2026·20 min read

On this page

  • Sequential Awaits on Independent Tasks
  • await Inside forEach
  • Promise.all Without Failure Handling
  • The Silent Missing await
  • await on a map That Returns Promises
  • When Sequential Is the Right Choice
  • Quick Decision Guide
  • Frequently Asked Questions