Back to Blog
jacken@blog:~$ cat async-await-best-practices-pitfalls.md

Async/Await Best Practices and Common Pitfalls

December 5, 202510 min readby Jacken Holland
JavaScriptAsyncBest Practices

Three years ago, I shipped a feature that brought down our production API. The cause? A forgotten await in a critical database query. The query returned a Promise, I treated it like data, and chaos ensued.

That bug taught me async/await isn't just syntax sugar—it's a fundamental shift in how JavaScript handles concurrency. Here are the lessons I wish I'd learned before that incident.

The Mental Model That Changed Everything

Here's what finally made async/await click for me: async functions always return Promises, and await pauses execution until a Promise resolves.

That's it. But the implications are huge:

// These are identical
async function getUser() {
  return { id: 1, name: "John" };
}

function getUser() {
  return Promise.resolve({ id: 1, name: "John" });
}

When you write async, you're opting into Promise-land. Even if you return a plain value, it gets wrapped in a Promise. This is why forgotten await keywords are so dangerous—you get a Promise where you expected data.

AI debugging tip: When I hit a weird async bug, I paste the function into Claude/GPT and ask: "Why is this async function not working as expected?" 90% of the time it's a missing await or incorrect error handling.

Error Handling: The #1 Source of Production Bugs

Poor error handling in async code has caused more outages than I care to admit. Here's what I do now:

Always Use Try/Catch

This seems obvious, but I see it violated constantly:

// ✗ Unhandled promise rejection waiting to happen
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// ✓ Handle errors explicitly
async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return response.json();
  } catch (error) {
    console.error("Failed to fetch user:", error);
    throw error; // Re-throw or handle appropriately
  }
}

Real talk: In 2025, tools like TypeScript with strict null checks and ESLint's no-floating-promises rule catch many of these issues. But you still need to think about error boundaries.

Centralized Error Handling

For complex operations, handle errors at the appropriate level:

async function loadUserDashboard(userId) {
  try {
    const [user, posts, notifications] = await Promise.all([
      fetchUser(userId),
      fetchPosts(userId),
      fetchNotifications(userId),
    ]);
    return { user, posts, notifications };
  } catch (error) {
    logError(error);
    showUserMessage("Couldn't load dashboard. Please try again.");
    return null;
  }
}

This pattern prevents error handling boilerplate in every sub-function. The top-level function owns error recovery.

AI workflow: When I'm unsure where to handle errors, I ask: "Where should I add try/catch blocks in this async function chain?" The AI suggests a strategy, I evaluate it against my app's error recovery needs.

Performance: Sequential vs Parallel Execution

This is the mistake that costs the most performance. I see it in code reviews constantly.

The Problem: Accidental Sequential Execution

// ✗ SLOW - waits 600ms total
async function loadData() {
  const users = await fetchUsers();      // 200ms
  const posts = await fetchPosts();      // 200ms
  const comments = await fetchComments(); // 200ms
  return { users, posts, comments };
}

These three fetches have no dependencies. They should run in parallel.

The Solution: Promise.all()

// ✓ FAST - waits 200ms total
async function loadData() {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchComments(),
  ]);
  return { users, posts, comments };
}

Real-world impact: I optimized a dashboard that loaded in 3.2 seconds down to 0.8 seconds just by converting sequential fetches to Promise.all(). Same data, 4x faster.

When Sequential Is Correct

Sometimes you need sequential execution:

async function createUserAndProfile(userData) {
  const user = await createUser(userData); // Must finish first
  const profile = await createProfile(user.id, userData.profile); // Needs user.id
  return { user, profile };
}

The second call depends on the first. Sequential is correct here.

Rule of thumb: If operations don't depend on each other's results, use Promise.all().

Modern Patterns for 2025

Promise.allSettled() for Partial Failures

Sometimes you want all results, even if some fail:

async function loadDashboard() {
  const results = await Promise.all Settled([
    fetchUsers(),
    fetchPosts(),
    fetchAnalytics(),
  ]);

  const users = results[0].status === "fulfilled" ? results[0].value : [];
  const posts = results[1].status === "fulfilled" ? results[1].value : [];
  const analytics = results[2].status === "fulfilled" ? results[2].value : null;

  return { users, posts, analytics };
}

If analytics fail, you still get users and posts. Much better UX than showing an error for everything.

Top-Level Await (ES2022)

In modules, you can now await at the top level:

// config.js
const config = await fetch('/api/config').then(r => r.json());
export default config;

This is huge for initialization code. No more IIFE wrappers or deferred loading patterns.

Async Iterators

For streaming data:

async function* fetchPages(url) {
  let page = 1;
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    if (data.length === 0) break;
    yield data;
    page++;
  }
}

// Usage
for await (const page of fetchPages('/api/items')) {
  console.log('Got page:', page);
}

I use this for paginated APIs, log streaming, and any "process data as it arrives" scenario.

Common Pitfalls (And How I Debug Them)

Pitfall 1: Forgetting await

// ✗ Returns Promise<User>, not User
async function getUser(id) {
  const user = fetchUser(id); // Missing await!
  return user.name; // TypeError: Cannot read property 'name' of Promise
}

How I catch this: ESLint rule @typescript-eslint/no-floating-promises with TypeScript. It yells at you immediately.

AI debugging: Paste the error and ask: "Why am I getting 'Cannot read property of Promise'?" The AI spots the missing await instantly.

Pitfall 2: Async Functions in Array Methods

// ✗ Doesn't work as expected
async function processUsers(users) {
  users.forEach(async (user) => {
    await updateUser(user); // forEach doesn't wait for promises
  });
  console.log("Done!"); // Logs before updates finish
}

// ✓ Use for...of
async function processUsers(users) {
  for (const user of users) {
    await updateUser(user);
  }
  console.log("Done!"); // Logs after all updates
}

// ✓ Or Promise.all for parallel
async function processUsers(users) {
  await Promise.all(users.map(user => updateUser(user)));
  console.log("Done!");
}

forEach, map, filter don't understand async. Use for...of or Promise.all().

Pitfall 3: Mixing Promises and Async/Await

Pick one style and stick with it:

// ✗ Confusing mix
async function getData() {
  return fetch("/api/data")
    .then(response => response.json())
    .then(data => processData(data));
}

// ✓ Consistent async/await
async function getData() {
  const response = await fetch("/api/data");
  const data = await response.json();
  return processData(data);
}

Mixing styles makes error handling unpredictable and confuses readers.

Using AI for Async Code

Here's my workflow in 2025:

  1. Write the logic: Get it working, even if inefficient
  2. Profile: Check if sequential fetches are slowing things down
  3. Ask AI for optimization: "Can I parallelize these async operations?"
  4. Review suggestions: Make sure dependencies are preserved
  5. Test error cases: Ensure failures are handled gracefully

Prompts I use regularly:

  • "Convert this sequential async code to use Promise.all where possible"
  • "Add proper error handling to this async function"
  • "Why is this async function returning a Promise instead of the value?"
  • "Help me debug this race condition in my async code"

The AI catches patterns I miss (especially forgotten await), but I own the error handling strategy.

Advanced Patterns

Retry with Exponential Backoff

async function retryWithBackoff(fn, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxAttempts) throw error;

      const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
      console.log(`Retry attempt ${attempt} in ${delay}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const data = await retryWithBackoff(() => fetchData());

Essential for dealing with flaky APIs or rate limits.

Debounced Async Functions

function debounceAsync(fn, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    return new Promise((resolve, reject) => {
      timeoutId = setTimeout(async () => {
        try {
          resolve(await fn.apply(this, args));
        } catch (error) {
          reject(error);
        }
      }, delay);
    });
  };
}

// Usage
const debouncedSearch = debounceAsync(searchAPI, 300);
const results = await debouncedSearch(query);

Perfect for search inputs, auto-save, any user-triggered async action.

Testing Async Code

In 2025, testing tools handle async natively:

// Jest/Vitest
test("fetches user data", async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe("John");
});

// With mocks
test("handles errors", async () => {
  vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
  await expect(fetchUser(1)).rejects.toThrow("Network error");
});

AI-assisted testing: I ask: "Write tests for this async function including error cases" and the AI generates comprehensive test cases. I review them, add edge cases it missed, done.

Wrapping Up

Async/await transformed JavaScript, but it's easy to shoot yourself in the foot. The patterns that matter:

  1. Always handle errors with try/catch
  2. Parallelize independent operations with Promise.all()
  3. Use for...of for async operations in loops
  4. Let AI catch missing awaits but own your error strategy
  5. Profile before optimizing - measure actual performance impact

The biggest mindset shift? Async functions always return Promises. Internalize that, and half the bugs disappear.

Related posts: Check out my guides on JavaScript array methods for handling async in map/filter, and TypeScript patterns for typing async functions.


Building React apps with async data? Read my post on type safety in React for patterns that catch async bugs at compile time.