Quick Answer
AI coding tools like Cursor, Bolt, and Lovable generate .then() chains without .catch(), try/catch blocks that only log the error and return undefined, and async functions with no error handling at all. The result is silent failures: your app continues running after a critical operation has failed, serving stale data or no data with no log entry and no user feedback. Every async call needs explicit, meaningful error handling.
The Silent Failure Problem in AI-Generated Code
A silent failure is when your code encounters an error, discards it, and continues as if nothing happened. The user sees a blank list instead of their data. A payment is attempted but not recorded. A file upload succeeds on the client but never reaches the server. No exception is thrown, no error is logged, and no alert fires.
Silent failures are the most dangerous class of bug in production applications because they are invisible by definition. You will not find them in your error tracking tool. You will not see them in your server logs. You will discover them when a customer reports that their account looks wrong, or when you run a data integrity audit and find thousands of orphaned records.
According to the DORA 2024 State of DevOps report, teams with robust error handling and observability resolve incidents 4x faster than teams without it. The bottleneck is detection time - and silent failures maximize detection time by producing no signal at all.
Veracode's State of Software Security report found that improper error handling is present in 70% of all applications scanned, making it one of the most pervasive code quality issues across the industry. In AI-generated codebases, the prevalence is higher because AI models optimize for the happy path.
What AI Tools Actually Generate
Here are three concrete examples of missing error handling, each representing a different pattern Cursor, Bolt, and Lovable generate regularly:
// ❌ BAD - Pattern 1: .then() with no .catch()
function loadUserProfile(userId) {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
});
// If the fetch fails, the promise rejects silently.
// setUser is never called. The UI freezes on empty state.
}
// ❌ BAD - Pattern 2: try/catch that swallows the error
async function saveOrder(orderData) {
try {
const order = await db.orders.create({ data: orderData });
return order;
} catch (err) {
console.log(err); // Logged but nothing is returned
// Caller receives undefined, proceeds as if save succeeded
}
}
// ❌ BAD - Pattern 3: async function with no error handling at all
async function sendWelcomeEmail(user) {
const response = await fetch('/api/send-email', {
method: 'POST',
body: JSON.stringify({ to: user.email, template: 'welcome' }),
});
const result = await response.json();
return result;
// If the email service is down, this throws an unhandled rejection
// Node.js process may crash, or the error surfaces in a confusing place
}
Each pattern fails differently. Pattern 1 causes a UI freeze. Pattern 2 creates phantom success - the caller thinks the save worked. Pattern 3 causes an unhandled promise rejection that, in Node.js 15+, terminates the process.
What Meaningful Error Handling Looks Like
// ✅ GOOD - Explicit error handling at every async boundary
// Pattern 1 fixed: .catch() on every .then() chain
function loadUserProfile(userId) {
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status} fetching user ${userId}`);
return res.json();
})
.then(data => setUser(data))
.catch(err => {
console.error('loadUserProfile failed:', err.message);
setUserError('Could not load profile. Please try again.');
});
}
// Pattern 2 fixed: re-throw so callers know about failures
async function saveOrder(orderData) {
try {
const order = await db.orders.create({ data: orderData });
return order;
} catch (err) {
console.error('saveOrder failed:', { orderData, err: err.message });
throw new Error(`Order save failed: ${err.message}`);
// Caller receives a thrown error, not undefined
}
}
// Pattern 3 fixed: check response status, handle failure cases
async function sendWelcomeEmail(user) {
try {
const response = await fetch('/api/send-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: user.email, template: 'welcome' }),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Email API error ${response.status}: ${body}`);
}
return await response.json();
} catch (err) {
// Log for observability, but do not crash the caller
console.error('sendWelcomeEmail failed:', err.message);
// Return a typed failure result instead of throwing
return { success: false, reason: err.message };
}
}
The Four Rules of Meaningful Error Handling
A catch block is not meaningful just because it exists. An empty catch block, or one that only calls console.log and returns nothing, is almost as bad as no catch block. Apply these four rules to every error handler in your codebase:
| Rule | Why it matters | Anti-pattern it prevents |
|---|---|---|
| Log with context | Stack traces alone are hard to search in production logs | catch (e) { console.log(e) } |
| Propagate or recover - never both | Decide at each level: handle it here or pass it up | Logging and returning undefined (phantom success) |
| Check HTTP response status | fetch only rejects on network failure, not 4xx/5xx |
fetch().then(r => r.json()) on error response |
| Give users actionable feedback | Silent errors produce confusing blank UI states | Setting state to null without an error message |
Special Case: Supabase and Prisma Error Patterns
Vibe-coded apps built on Supabase or using Prisma with Next.js have specific error patterns AI tools consistently mishandle.
Supabase client methods return { data, error } tuples. They do not throw. AI tools often check data without checking error, or check neither:
// ❌ BAD - Supabase error ignored
const { data } = await supabase.from('orders').select('*').eq('user_id', userId);
setOrders(data); // data is null on error, error object is discarded
// ✅ GOOD - Supabase error handled
const { data, error } = await supabase.from('orders').select('*').eq('user_id', userId);
if (error) {
console.error('Failed to fetch orders:', error.message);
setOrdersError(error.message);
return;
}
setOrders(data);
Prisma throws exceptions on constraint violations, connection failures, and invalid queries. These need try/catch in every calling function - not just at the route handler level.
How to Find Missing Error Handling in Your Codebase
Manual review is unreliable for this class of bug. The patterns are subtle and spread across every async call in the project. Automated approaches:
- TypeScript's
no-floating-promisesrule: Catches unawaited async calls - the source of most unhandled rejections - ESLint
no-emptyandno-empty-catch: Flags empty catch blocks that swallow errors silently - Search for
.then(without adjacent.catch(: A quick grep reveals uncaught chains - Search for
catchblocks containing onlyconsole.log: These are pseudo-handlers that do nothing useful
Tools like VibeDoctor (vibedoctor.io) automatically scan your codebase for missing error handling and flag specific file paths and line numbers. Free to sign up.
FAQ
What is the difference between an unhandled promise rejection and a silent failure?
An unhandled promise rejection produces a visible warning or crash (in Node.js 15+, an unhandled rejection terminates the process). A silent failure swallows the error - via an empty catch or a catch that returns undefined - so the application continues running as if the operation succeeded. Silent failures are harder to detect because they produce no signal.
Should I always re-throw after logging an error?
It depends on the layer. Service functions should generally re-throw so callers can decide how to respond. UI event handlers should usually catch and display an error state rather than re-throw. The key is being deliberate: decide at each level whether you are handling the error or delegating it.
Does fetch() reject on a 404 or 500 response?
No. The fetch() API only rejects on network-level failures (DNS failure, refused connection, timeout). HTTP error status codes - 400, 401, 404, 500 - resolve the promise with response.ok === false. You must check response.ok or response.status explicitly after every fetch call.
How do I handle errors globally without wrapping every call?
In Next.js, the error.tsx file in App Router provides a React error boundary for route segments. For API routes, a wrapper function or middleware can add consistent error logging and response formatting. These reduce boilerplate but do not replace meaningful error handling at the data layer.
Why does Cursor generate code without error handling?
Cursor and similar AI tools optimize for working code in the happy path. Error handling adds lines without advancing the feature. When you prompt "fetch user data," the model demonstrates success, not failure. Explicitly prompting "with full error handling and user-visible error states" produces better results.