Your customer clicks Pay. The spinner resolves, the page says thank you, and they close the tab. On your side there is nothing: no log line, no alert, no red entry in any dashboard, because as far as your server is concerned the request succeeded. Somewhere inside the handler an error was caught and dropped, and the response went out as 200 OK. The card was never charged.

That is a silent failure: an error your app catches, records nowhere, and papers over with a success response. In apps built with AI tools it sits closer to the default than the exception. Of the 21 third-party apps audited for the AxonBuild corpus, 17 recorded errors nowhere; when a customer hits a bug in one of those apps, nothing writes it down and the failure disappears. The tools produce this shape for a mechanical reason. The fastest way to make an error stop showing up is to catch it and do nothing, which is exactly what a fix-this-error prompt optimizes for, and an error that stopped showing up looks identical, from the outside, to an error that got fixed.

The rest of this post is the anatomy of that failure: the swallowed error, the status code that lies, the render crash nobody hears, and then the short list of changes that makes a failure loud enough to reach you before it reaches a customer.

The empty catch block that “fixed” it

An empty catch block hides an error instead of handling it. Tell an AI assistant “this is throwing an error,” and the change that makes the error disappear fastest is a try/catch that catches everything and does nothing, followed by a success response, because the function was supposed to return one.

async function handleCheckout(req, res) {
  try {
    await chargeCard(req.body);
    await grantAccess(req.body.userId);
  } catch (e) {
    // swallow it so the error stops showing up
  }
  res.status(200).json({ ok: true }); // "success", even when the charge threw
}

That handler is the checkout from the top of this post. chargeCard threw, the catch ate it, and res.status(200) ran anyway, because it sits below the try block and runs on every path. The customer got a thank-you page for a purchase that never happened, and the only person who will ever report it is the one who notices the charge never hit their card.

The tell is catch {} or catch (e) {} with nothing inside, .catch(() => {}) on a promise, or, barely better, a console.log(e) on a server whose logs nobody reads. Each one is a place where a failure got downgraded to a rumor.

Why your API says 200 when the request failed

The status code is doing exactly what it was designed to do; the trouble is which contract it covers. MDN’s definition of 200 is deliberately narrow: the request succeeded, with a meaning that varies by HTTP method. The claim is about transport: the server received the request and produced a response. Whether the money moved, the row was written, or the email went out is a second contract, and your business logic has to honor it on its own, because HTTP won’t do it for you.

A handler that returns 200 on its failure branch breaks that second contract, and the damage spreads past one confused customer, because retry logic, frontend error states, and uptime monitors all key off the status code. Answer 200 for a failure and the client’s fetch resolves cleanly, no retry fires, no error message renders, and the monitoring stays green. The one signal every layer of the stack agrees to listen to is announcing that nothing is wrong.

The repair is one rule: a handler that can fail must be able to answer with something other than 200, a 500 when the server broke, an appropriate 4xx when the request couldn’t be honored.

One thrown render, a white screen

The mirror image of the swallowed error is the error nobody caught at all. In a React app, one component that throws while rendering takes the whole tree down, and by default React removes the UI from the screen, leaving a blank page. The trigger is usually the most boring thing imaginable: a field that is almost always there and occasionally isn’t.

// Fine for every record until the first one where `profile` is null:
<span>{user.profile.displayName}</span>

A crash in the browser never reaches your server logs unless something reports it, so the first customer whose data doesn’t match the shape the AI assumed gets a white screen, and you get nothing. An error boundary is the guardrail: it catches the thrown render, shows a fallback instead of a blank page, and hands you a hook for sending the error somewhere you will see it. That last clause is the half that pays.

Who tells you first, a dashboard or a customer?

Ask yourself how you would find out if your app failed for a real user right now. For most vibe-coded apps the honest answer is a support email, which means every silent failure stays invisible until it has already cost a customer their afternoon and you some of their goodwill.

One of the audits stays with me here. A voice-AI SDK, the most mature codebase in the corpus: strict TypeScript, Docker, ephemeral tokens, a committed lockfile. Its token server minted paid LLM sessions against the owner’s own API key for anyone on the internet, no authentication, no rate limit, and the repo’s deploy script shipped with --allow-unauthenticated baked in. I traced what would happen if a stranger found the endpoint and looped it: no record kept, no alert sent. The owner’s first sign of trouble would have been the provider’s bill.

Looks fine
Actually fine
The app has never crashed for you
You get an alert within minutes when it fails for someone else
The checkout returns 200
A failed charge logs the error and pages you, instead of answering 200
Errors show up in the browser console
Errors are reported to a service you actually watch, from the server too
Looks fine
The app has never crashed for you
The checkout returns 200
Errors show up in the browser console
Actually fine
The app has never crashed for you
You get an alert within minutes when it fails for someone else
The checkout returns 200
A failed charge logs the error and pages you, instead of answering 200
Errors show up in the browser console
Errors are reported to a service you actually watch, from the server too

The instrumentation that closes this gap is minutes of setup rather than a project. An error-tracking SDK is a short install and a Sentry.init call (Sentry docs), and it catches both halves of this post: the exceptions you re-throw instead of swallowing and the renders that blow up in the browser. An uptime monitor that pings a health endpoint every five minutes and emails you when it stops answering is free. Neither asks you to predict the failure in advance. That is the whole design: you can’t anticipate every way an app fails, so you instrument it to report whatever happens. The same missing wiring is what turns a quiet bad deploy into a long outage; one database, no staging walks that version of the problem. And when the thing that fails is security rather than a checkout, the same question decides whether your AI-built app needs a security audit.

A failure you hear about from a dashboard costs you minutes. The same failure, reported by a customer, costs you the customer.

The test that runs vs. the test that exists

Monitoring tells you when something already failed. A test on your two money paths, signup and checkout, tells you before you ship the change that breaks them. But a test only counts if it runs on every push, and it only proves something if it actually executes the path it claims to cover.

The purest version I’ve audited was a retail point-of-sale app: an 818-line test suite, green on every run. Then I traced what the tests actually exercised, and the real sale-creation path, the one function that takes money, was never called once; the suite had also drifted from the code it claimed to cover. The checkmarks were green and the checkout was untested. Green tests were telling the owner the same lie the 200 was telling the customer. And that app was no outlier: at least 23 of 26 apps in the corpus had zero working automated tests.

The silent-failure pattern and the missing-test pattern are two views of the same blind spot: nothing is watching the money path. Six ways your vibe-coded checkout leaks money covers the payments side of that door, and tracing whether your green suite ever executes your checkout is precisely what the Beyond the Demo Audit does: an audit that traces swallowed errors and untested money paths, file and line included.

Make failure loud: silent-failure handling in one afternoon

Catching every bug before it happens is out of reach for everyone. Silent-failure handling is a humbler and more achievable goal: make sure any bug that does happen is impossible to miss. Six changes cover it, and together they fit inside one afternoon.

  • Grep the codebase for catch {}, catch (e) {}, and .catch(() => {}). Every empty catch is a decision to hide a failure. Replace each with one that logs the error and, on a money path, refuses to return success.
  • Return the right status on the failure branch. A handler that can fail must be able to answer something other than 200.
  • Wrap the app in an error boundary so one thrown render shows a fallback instead of a white screen, and reports the error.
  • Add error tracking (Sentry.init or equivalent) so browser and server exceptions reach a place you watch.
  • Put an uptime monitor on a real health check, one that exercises the database rather than a route that always answers “ok”.
  • Keep a test on signup and checkout that runs on every push, and confirm it calls the real functions.

Every item on that list converts a failure you would learn about from a customer into one you learn about from a dashboard. That swap, from hoping the app is fine to knowing within minutes when it isn’t, is most of what “production-ready” means in practice. Silent failures are one pillar of the larger launch question; is your AI-built app actually ready to launch maps the rest of it, and the scorecard will name your weakest area in about two minutes.

Common questions about silent failures

Is it okay to return HTTP 200 with an error in the response body?

Some APIs do it deliberately: GraphQL answers 200 with an errors array, and RPC-style APIs wrap everything in an envelope. That works when every client is built to parse the envelope. For a typical AI-built app the practical answer is no, because retries, error states, and monitors all read the status code, and an error buried inside a 200 body is invisible to every one of them.

What should my API return when the request failed?

Whatever status the failure deserves: 500 when the server broke, a specific 4xx when the request couldn’t be honored, 402 for a failed payment if you want to be precise. The split matters more than the number. Success and failure must be distinguishable without parsing the body.

How do I know if my app is failing for users right now?

Without error tracking and an uptime monitor, you don’t, and that is the common case: 17 of the 21 third-party apps in the corpus had no way to know. The fastest route to an answer is an error-tracking SDK today, an uptime check on a real health endpoint, and a grep for empty catch blocks, in that order.

Is an empty catch block ever acceptable?

When the operation is genuinely optional and you can say why in a comment: a best-effort analytics ping, a cache warm that can fail harmlessly. Even then, log the error. Legitimately empty catches are rare enough that each one deserves a written justification, and none of them belong on a path that touches money or data.