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:
await fetch(without surrounding try/catchawait db.orawait prisma.outside error boundariesPromise.allwith operations that can fail independentlycatchblocks that don't log, return, or re-throw
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:
- All external calls (APIs, databases, file systems) have error handling
- Catch blocks either recover, log and re-throw, or return meaningful errors
- Retry logic preserves the final error instead of returning undefined
- 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