Fairy
Resources

How to Avoid: Async without Error Handling

July 12, 2026 · 8-minute read · Fairy

The short answer

To prevent async without error handling in AI-generated code, wrap every awaited external call in a try/catch block and handle failures explicitly. AI models often omit error handling because their training data favors happy-path examples. Unhandled promise rejections can crash Node.js processes or return silent 500 errors, making this a critical production safety issue.

The Direct Answer: Wrap Awaited External Calls in Try/Catch

Preventing async without error handling requires wrapping every awaited external call—API requests, database queries, file operations—in a try/catch block with explicit failure handling. AI-generated code frequently omits this because training data favors clean, happy-path examples. The fix is straightforward: add try/catch, log the error, and return or throw appropriately.

// ❌ AI-generated pattern: no error handling
async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data;
}

// ✅ Correct pattern: explicit error handling
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`Failed to fetch user ${userId}:`, error.message);
    throw error; // Re-throw for caller to handle, or return a default
  }
}

This pattern prevents unhandled promise rejections from crashing your Node.js process or returning silent 500 errors in production.

Why AI Generates Async Code Without Error Handling

AI code generation models learn from massive datasets of public code. This training data has a systematic bias: example code, tutorials, and documentation prioritize readability over production-readiness. Error handling adds visual noise, so authors strip it out to focus on the core concept.

When you prompt an AI to "fetch user data from an API," it reproduces the patterns it learned—clean, minimal, and missing the defensive code production systems require.

The Training Data Problem

Consider how most async/await tutorials look:

// Typical tutorial example
const users = await db.query('SELECT * FROM users');
console.log(users);

This demonstrates the syntax clearly. It also creates a mental model where error handling is optional or secondary. AI models internalize this hierarchy: the await is essential, the try/catch is decoration.

The Happy-Path Bias

AI models optimize for the most likely completion given the prompt. Since most training examples show successful operations, the model predicts success as the default outcome. It's not that the AI doesn't "know" about error handling—it's that error handling is statistically less common in its training distribution.

This creates a dangerous gap between AI-generated code that works in development (where networks are fast and databases are local) and production (where everything fails eventually).

What Happens When Async Errors Go Unhandled

Unhandled promise rejections have different consequences depending on your runtime and configuration, but none of them are good.

Node.js Process Crashes

Starting with Node.js 15, unhandled promise rejections crash the process by default. Your server stops, in-flight requests fail, and you need restart mechanisms to recover.

// This will crash Node.js 15+ if the fetch fails
async function updateInventory() {
  const stock = await fetch('https://inventory-api.example.com/stock');
  // Network timeout? DNS failure? API down? Process crashes.
}

Silent 500 Errors in Web Frameworks

Express, Fastify, and other Node.js frameworks catch unhandled errors in route handlers, but the default behavior is a generic 500 response with no useful information.

app.get('/api/order/:id', async (req, res) => {
  const order = await db.orders.findOne({ id: req.params.id });
  const shipment = await shippingApi.getStatus(order.trackingId);
  // If shippingApi times out, user sees "Internal Server Error"
  // No logging, no context, no way to debug
  res.json({ order, shipment });
});

Users report "the page doesn't work." Your logs show nothing. You spend hours reproducing an intermittent failure that only happens when a third-party API is slow.

Cascading Failures

Unhandled errors in one async operation can poison others. Consider a batch processing function:

// ❌ One failure breaks the entire batch silently
async function processOrders(orderIds) {
  const results = await Promise.all(
    orderIds.map(id => processOrder(id)) // No error handling per order
  );
  return results;
}

If processOrder throws for any single order, Promise.all rejects immediately. The other orders might have succeeded, but you've lost track of which ones. State becomes inconsistent.

The Correct Patterns for Async Error Handling

Different situations call for different approaches. Here are the patterns that work in production.

Pattern 1: Try/Catch with Specific Recovery

When you need to handle different errors differently:

async function createSubscription(userId, planId) {
  try {
    const customer = await stripe.customers.retrieve(userId);
    const subscription = await stripe.subscriptions.create({
      customer: customer.id,
      items: [{ price: planId }],
    });
    return { success: true, subscription };
  } catch (error) {
    if (error.code === 'resource_missing') {
      // Customer doesn't exist in Stripe - create them first
      return createCustomerAndSubscribe(userId, planId);
    }
    if (error.code === 'card_declined') {
      return { success: false, error: 'payment_failed', retry: true };
    }
    // Unknown error - log and re-throw
    console.error('Subscription creation failed:', error);
    throw error;
  }
}

Pattern 2: Error Boundaries for Route Handlers

Wrap entire route handlers to ensure consistent error responses:

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch((error) => {
    console.error(`[${req.method}] ${req.path}:`, error.message);
    res.status(500).json({ 
      error: 'Internal server error',
      requestId: req.id // For debugging
    });
  });
};

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
}));

Pattern 3: Per-Item Error Handling in Batches

When processing multiple items, isolate failures:

async function processOrders(orderIds) {
  const results = await Promise.allSettled(
    orderIds.map(async (id) => {
      try {
        return { id, result: await processOrder(id) };
      } catch (error) {
        return { id, error: error.message };
      }
    })
  );
  
  const succeeded = results.filter(r => r.status === 'fulfilled' && !r.value.error);
  const failed = results.filter(r => r.status === 'rejected' || r.value?.error);
  
  console.log(`Processed ${succeeded.length}/${orderIds.length} orders`);
  if (failed.length) console.error('Failed orders:', failed);
  
  return { succeeded, failed };
}

Pattern 4: Retry with Error Capture

When retrying transient failures, preserve error information:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      return await response.json();
    } catch (error) {
      lastError = error;
      console.warn(`Attempt ${attempt}/${maxRetries} failed:`, error.message);
      
      if (attempt < maxRetries) {
        await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 100));
      }
    }
  }
  
  throw new Error(`Failed after ${maxRetries} attempts: ${lastError.message}`);
}

This pattern addresses a common AI-generated bug where retry helpers silently swallow errors and return undefined after exhausting retries, leaving callers with no indication of what failed.

Related Error Handling Gaps in AI Code

Async without error handling often appears alongside other defensive coding gaps.

External Calls Without Timeouts

AI-generated fetch calls rarely include timeouts. When a third-party API hangs, your request hangs too:

// ❌ No timeout - can hang indefinitely
const data = await fetch('https://slow-api.example.com/data');

// ✅ With timeout using AbortController
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch('https://slow-api.example.com/data', {
    signal: controller.signal
  });
  clearTimeout(timeout);
  return await response.json();
} catch (error) {
  clearTimeout(timeout);
  if (error.name === 'AbortError') {
    throw new Error('Request timed out after 5 seconds');
  }
  throw error;
}

Empty Catch Blocks

Sometimes AI includes try/catch but leaves the catch block empty or with just a comment:

// ❌ Error swallowed completely
try {
  await sendEmail(user.email, template);
} catch (error) {
  // Handle error
}

This is arguably worse than no try/catch—the error is now invisible instead of crashing the process where someone might notice.

How to Detect Missing Error Handling

Static Analysis

ESLint with TypeScript can catch many cases:

{
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "no-empty": ["error", { "allowEmptyCatch": false }]
  }
}

Code Review Signals

When reviewing AI-generated code, search for these patterns:

Automated Review Tools

Fairy Scout flags async-without-error-handling as a warning-level issue in AI-generated pull requests. It identifies awaited external calls that lack try/catch blocks and suggests the appropriate error handling pattern for the context.

Integrating Error Handling Checks into Your Workflow

Pre-Merge Review

Every PR containing AI-generated async code should verify:

  1. All external calls (APIs, databases, file systems) have error handling
  2. Catch blocks either recover, log and re-throw, or return meaningful errors
  3. Retry logic preserves the final error instead of returning undefined
  4. Timeouts exist on third-party HTTP calls

Runtime Monitoring

Even with perfect static analysis, add runtime monitoring:

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', reason);
  // Send to error tracking service
  errorTracker.captureException(reason);
});

This catches any gaps in your error handling before they crash production.

The Bottom Line

AI-generated async code defaults to happy-path patterns because that's what dominates training data. Production code needs defensive patterns: try/catch around external calls, meaningful error recovery, and preserved error context for debugging.

The fix isn't complex—it's about consistent application of straightforward patterns. Wrap external awaits in try/catch. Log errors with context. Either recover, re-throw, or return meaningful failure states. Never silently swallow errors.

For teams deploying AI-generated code, systematic review for error handling gaps should be part of the merge process. Tools like Fairy for Code automate this detection, flagging async-without-error-handling patterns before they reach production. The goal is catching these issues early—during review, not during an incident.

Frequently asked questions

Why does AI-generated code often miss error handling on async calls?

AI models learn from training data that skews toward happy-path examples and clean demonstrations. Error handling adds visual complexity, so examples often omit it. The AI then reproduces this pattern, generating await calls without try/catch blocks.

What happens when an async function throws without a try/catch?

The promise rejects and becomes an unhandled rejection. In Node.js 15+, this crashes the process by default. In web servers, it typically returns a 500 error with no logging, making failures invisible until users report them.

Should I wrap every single await in try/catch?

Not necessarily. Wrap awaited external calls (APIs, databases, file systems) individually when you need specific error recovery. For internal calls where you want errors to propagate, a single outer try/catch or error boundary is appropriate.

How do I detect missing error handling in my codebase?

Use ESLint rules like no-floating-promises and require-await. Review AI-generated code for await statements outside try blocks, especially those calling fetch, database clients, or third-party SDKs. Automated code review tools can flag these patterns.

What's the difference between try/catch and .catch() for async error handling?

Both work, but try/catch is generally clearer with async/await syntax. The key is consistency: pick one pattern and apply it everywhere. Mixing styles makes it harder to verify complete coverage during code review.


Have AI-generated work you’d want verified? Connect with a Fairy → or run a free check with Scout.

More resources