← All Articles 🧹 Code Quality Medium By Nishant Kumar Verma Updated

Mixed Async Patterns in JavaScript: .then() vs async/await in AI Code

AI tools mix .then()/.catch() with async/await in the same file. Learn why this causes bugs and how to standardize your async code.

QUA-005

Quick Answer

Mixing .then()/.catch() and async/await in the same file creates inconsistent error handling, confusing control flow, and subtle bugs - especially when developers forget that .then() chains and await expressions behave differently around rejection and return values. Pick one style per project and apply it consistently. For new code, prefer async/await.

Why AI Tools Generate Mixed Async Code

When you build with Cursor, Bolt, Lovable, or v0, each prompt generates code in isolation. One prompt builds your API call with async/await. A later prompt adds a feature using .then()/.catch() because that example happened to be in the training data. A third prompt wraps both in another async function. Before long, a single file reads like three different developers wrote it - because in a sense, three different AI contexts did.

In the Stack Overflow Developer Survey 2024, JavaScript was again the most-used language (62.3% of respondents), as it has been every year since 2015. Most new code uses async/await, but .then() chains still show up in AI-generated code, because the models were trained on older code too.

Inconsistent async patterns are common in JavaScript files that contain async operations. Mixing styles in the same file makes it easy to miss an unhandled rejection or a forgotten await.

What Mixed Async Code Actually Looks Like

Here is a realistic example of what Cursor or Bolt might produce across multiple prompts in a Next.js API route:

// ❌ BAD - Mixed async patterns in one file

// Style 1: .then()/.catch() for the database call
function getUserData(userId) {
  return db.users.findUnique({ where: { id: userId } })
    .then(user => {
      return user;
    })
    .catch(err => {
      console.error(err);
      return null;
    });
}

// Style 2: async/await for the API call added later
async function fetchUserProfile(userId) {
  const user = await getUserData(userId);
  const profile = await fetch(`https://api.example.com/profiles/${userId}`)
    .then(res => res.json())   // .then() mixed INTO an await expression
    .catch(() => null);
  return { user, profile };
}

// Style 3: Callback buried inside a .then() chain
async function syncUserToStripe(userId) {
  getUserData(userId).then(user => {
    stripe.customers.create({ email: user.email }, (err, customer) => {
      if (err) console.error(err);
      db.users.update({ where: { id: userId }, data: { stripeId: customer.id } })
        .then(() => console.log('synced'))
        .catch(console.error);
    });
  });
  // No return, no await - fire and forget, silently
}

The third function is particularly dangerous. It returns nothing, so the caller has no way to know if the Stripe sync succeeded. Errors never reach the caller: some are only logged, and a thrown one escapes as an unhandled rejection. The database update inside the callback is not awaited. Worse, stripe-node removed callback support in version 22, so on a current SDK the callback never runs at all: the customer is created and its ID is never saved.

The Correct Approach: Consistent async/await

// ✅ GOOD - Consistent async/await throughout

async function getUserData(userId) {
  try {
    const user = await db.users.findUnique({ where: { id: userId } });
    return user;
  } catch (err) {
    console.error('getUserData failed:', err);
    return null;
  }
}

async function fetchUserProfile(userId) {
  const user = await getUserData(userId);
  let profile = null;
  try {
    const res = await fetch(`https://api.example.com/profiles/${userId}`);
    profile = await res.json();
  } catch (err) {
    console.error('fetchUserProfile failed:', err);
  }
  return { user, profile };
}

async function syncUserToStripe(userId) {
  const user = await getUserData(userId);
  if (!user) throw new Error(`User ${userId} not found`);

  const customer = await stripe.customers.create({ email: user.email });
  await db.users.update({
    where: { id: userId },
    data: { stripeId: customer.id }
  });
  console.log('synced:', userId);
}

Every async operation is awaited. Errors are caught in explicit try/catch blocks. The function returns a value the caller can depend on. Failures are visible, not silent.

Three Specific Bugs Mixed Patterns Cause

It is easy to dismiss this as a style issue. It is not. Mixed async patterns cause real bugs:

1. Unhandled promise rejections. When a .then() chain inside an async function is neither awaited nor returned, a rejection in it is not caught by the surrounding try/catch. Older Node.js versions emitted an UnhandledPromiseRejectionWarning; since Node.js 15, an unhandled rejection crashes the process by default.

2. Silent fire-and-forget. A common mistake in Bolt-generated code is calling an async function inside a .then() callback without await. The inner async operation starts, the outer chain continues, and any failure disappears without a trace. This is how data mutations get lost in production.

3. Race conditions from mixed return timing. A function that sometimes returns a resolved promise (via .then()) and sometimes an async result can behave differently depending on microtask queue ordering. These bugs are notoriously difficult to reproduce.

How to Fix Mixed Async Patterns Across a Codebase

Fixing a mixed-async codebase by hand is tedious but straightforward. The steps:

  1. Enable the ESLint rule prefer-promise-reject-errors and consider no-promise-executor-return to catch some common misuse patterns automatically.
  2. Use the no-floating-promises TypeScript ESLint rule if you are on TypeScript. It catches unawaited async calls - the primary source of silent failures.
  3. Search for .then( in files that contain async function - these are your mixed files. Rewrite .then()/.catch() chains as try/await/catch blocks.
  4. Audit every function that calls an async function without await. If it is not intentionally fire-and-forget, add await and propagate the error.

VibeDoctor's Vibe Check flags async functions that also use .then() chains (QUA-005), with the file path and line number. Free to sign up.

When .then() Is Acceptable

There are legitimate cases for .then() in modern JavaScript. Stream processing, chaining independent transforms, and library APIs that return non-awaitable thenables are all reasonable. The rule is consistency: if a file uses .then() chains throughout, keep it consistent. Do not mix styles within the same function or within closely related functions in the same module.

Pattern Readability Error handling Stack traces Recommended
async/await only High try/catch blocks Clear, full stack Yes
.then()/.catch() only Medium Chained .catch() Often truncated Legacy code only
Mixed in same function Low Inconsistent, gaps Confusing Never
await + inline .then() Low Unpredictable Hard to trace Never

FAQ

Is mixing .then() and async/await actually a bug or just a style issue?

Both. It is a style issue that frequently produces bugs. Inconsistent patterns lead to missed rejections, fire-and-forget mutations, and race conditions. Style rules exist precisely to prevent these error categories.

Does ESLint catch mixed async patterns?

Standard ESLint rules do not flag this directly, but the TypeScript ESLint plugin's no-floating-promises and no-misused-promises rules catch the most dangerous class of errors. To flag every .then() chain, add unicorn/prefer-await from eslint-plugin-unicorn or promise/prefer-await-to-then from eslint-plugin-promise.

Why does Cursor keep generating .then() even when the rest of my code uses async/await?

AI models generate code based on the example in the prompt context. If your prompt or the surrounding code includes .then(), the model will mirror that style. Include explicit instructions like "use async/await throughout" in your system prompt or Cursor rules file.

Can I convert .then() chains to async/await automatically?

Yes. VS Code has a built-in code suggestion for JavaScript and TypeScript that converts a chain of .then() calls to async/await, and it handles many cases. For complex chains with branching logic, manual review is still required, and check that error handling behaves the same after the conversion.

Does this apply to React components?

Yes. In React, async work inside useEffect, whether it uses await or .then(), can finish after the component unmounts or after its inputs change, and then set stale state. Pick one style inside effects and return a cleanup function that ignores or aborts the late result, as the React docs show.

Diagnose your codebase - free

VibeDoctor checks for QUA-005 and 148 other issues across 21 diagnostic areas - security, performance, code quality, and more.

SCAN MY APP →