A customer clicks Pay. The page says thank you, but the charge failed before access was granted. If the server caught the error, recorded nothing, and still returned 200 OK, every automated signal now describes the request as a success.
That is a silent failure: the application loses the evidence of an error or reports the wrong outcome to its caller. In the historical AxonBuild cohort, 17 of 21 third-party apps recorded errors nowhere. This result is limited to those audited apps. The remediation applies broadly: preserve the error, return an outcome the client can distinguish, and alert on failures that need attention.
Why an API returns 200 but no data
A 200 with an empty body means one of two things: the request worked and there was genuinely nothing to send back, or something removed the data on the way and nobody recorded it. The status line cannot tell those apart, so the answer is always in the body, the request headers, and the server log for that same request.
Six causes are worth separating.
| What you see | What is actually happening |
|---|---|
200 with an empty array [] and nothing in the table | The query ran and matched nothing. For a collection, this is the correct response, not a fault. |
200 with an empty array and error null, but the rows exist | A row level security policy or a stray filter excluded every row. The database returned nothing, so the API returned nothing. |
200 with no data from an endpoint that needs a login | The token was missing, expired, or attached in the wrong place, so the request ran as an anonymous caller. The API filtered the rows instead of returning 401. |
200 with data that does not match the call you thought you made | Wrong parameters, wrong endpoint, or wrong environment. A call that works in Postman and fails in the app is usually two different requests. |
200 in the browser while the server log shows an exception | A reverse proxy, API gateway, serverless wrapper, or catch-all error middleware rewrote the response before it reached the client. |
200 carrying an error inside the JSON | An envelope API. The HTTP status describes the transport, and the real outcome sits in a field such as meta.code or an errors array. |
The first row is a working API. A documented envelope in the last row can be legitimate too, but every client and monitor must read the outcome field. The middle four need comparison with the intended request and access rules before you can say whether the empty or rewritten result is a failure.
Supabase and RLS: 200 with an empty array and no error
One documented Supabase version is a query that comes back like this:
const { data, error } = await supabase.from("orders").select("*");
// data: []
// error: null
Nothing throws, nothing is logged, and the UI renders its empty state. Supabase’s own note on this says an empty data array “usually means you have RLS (row level security) enabled and no policy, or do not meet the policy”, and that it can also mean a filter matched no rows (why is my select returning an empty data array). A policy that excludes every row is indistinguishable from an empty table on the client.
Two checks, in this order:
- Re-run the same query server-side with the service role key, which bypasses RLS. If rows come back, the data is there and a policy is filtering it. Keep that key on the server, never in the browser.
- Read the policy and the caller together. If the policy depends on
auth.uid()and the request never carried a session, it arrives asanonand matches nothing. Supabase suggests testing that by temporarily pointing the policy at theanonrole: if the query then works, the JWT was never in the authorization header.
This mismatch can occur in a generated client when the code that creates the table and the code that writes the policy are produced at different moments. Neither step necessarily surfaces a mismatch the owner can see. The write-side version of the same problem is loud instead of silent: new row violates row-level security policy.
One related trap: .single() does not return an empty array. PostgREST answers a singular request that matched no rows with 406 and error code PGRST116, so the same missing row is silent through .select() and loud through .single().
How to tell which one you have in five minutes
Run these in order and stop at the first step that explains the empty body.
- Open DevTools, go to the Network tab, and repeat the action that fails. Select the API request itself, not the page load.
- Compare the Response tab with the Preview tab for the same selected request. Response shows the returned body and Preview shows the browser’s parsed view of that body. Confirm the request URL and inspect the raw response before deciding that the two tabs represent different calls.
- Read
content-lengthandcontent-typeon the response, then inspect the body itself. Two bytes of JSON could be[],{}, or"". A zero-length body with a200proves only that this response carried no body; the handler, middleware, or API contract explains why. - Re-run the same call with
curl -iso you see the raw status line and headers with no framework or client library in the way. - Diff that request against one that works: same URL, same query parameters, same
Authorizationheader, same environment. A difference here can explain why one call returns data and the other does not. - Find that same request in the server log by request ID and read what the handler did. No matching log line establishes an observability gap, not a swallowed exception. Check routing, sampling, retention, and request correlation before deciding whether the request reached this handler.
curl -i -H "Authorization: Bearer $TOKEN" \
"https://api.example.com/v1/orders?status=paid"
HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 2
[]
That response is not broken HTTP. It is a working API saying it found no orders. Whether that is correct depends on whether orders exist and whether the caller is allowed to see them, which is what steps 5 and 6 settle.
The empty catch block that hides the cause
An empty catch block is one way a 200 starts reporting the wrong outcome: it converts a rejected operation into ordinary control flow. A lying 200 can carry almost anything:
- an empty body, where the handler returned before it wrote a response;
- an empty array, where the query ran and matched nothing;
- a stack trace, where a debug error page went out with a success status;
- invalid JSON, where the response was truncated or the client is parsing an HTML error page;
- an error object in the payload, where the real outcome sits in a field no monitor reads;
- a file that should never have been served, such as
.env, returned with200because a static route matched it.
None of those bodies proves by itself that an exception was swallowed. An empty collection can be a legitimate result. A documented envelope can carry an application error under a transport-level 200. An intentionally empty success can also be valid, although a conventional HTTP API would normally use 204. Compare the response with the endpoint’s contract and the server-side outcome before calling it false success.
async function handleCheckout(req, res) {
try {
await chargeCard(req.body);
await grantAccess(req.body.userId);
} catch (error) {
// The error disappears here.
}
res.status(200).json({ ok: true });
}
In this example, chargeCard can throw and the handler still sends { ok: true }. Search for catch {}, a catch that only returns a fallback, and promise handlers such as .catch(() => {}). Each hit needs a decision:
- an expected failure that should become a defined client response;
- an unexpected failure that should be recorded and returned as a server error;
- a genuinely optional operation whose failure may be ignored, with a comment explaining why.
Logging is part of handling, but console.error(error) alone may be inadequate. The record needs enough context to connect the failure to a request without storing secrets, card data, access tokens, or unnecessary personal data. A request or trace ID, operation name, sanitized error class, timestamp, and deployment version are usually more useful than a dump of the whole request body.
What 200 OK actually claims
The HTTP standard says a 2xx status means the client’s request was successfully received, understood, and accepted. 200 OK specifically means the request succeeded, with details that depend on the request method. It does not independently inspect whether your database changed or a provider charged a card. Your handler chooses the status, so the status is trustworthy only when every return path preserves the application’s outcome.
The earlier checkout handler violates that contract. A conventional HTTP API should keep these outcomes distinguishable:
| Outcome | Response behavior |
|---|---|
| The requested operation completed | Return the documented 2xx response and its result |
| The operation succeeded and there is nothing to send back | Return 204 No Content and no response body |
| The caller supplied invalid input or lacks permission | Return the matching 4xx response with a stable application error code |
| The server or a required dependency failed | Return an appropriate 5xx response and record the failure |
| The operation is still processing | Return the API's documented asynchronous response and a status resource or job ID |
Three statuses get confused constantly here. An empty collection is a success: return 200 with an empty array, not 404. A 404 belongs to a single resource you addressed by id that does not exist. And 204 No Content means the server fulfilled the request and has no content to send back, such as a delete, not that a search came up empty.
RFC 9110 defines the status classes. Pick the exact status from the real cause and the API contract. Avoid turning the status-code list into guesswork: 400 is not a generic substitute for every rejected business operation, and 500 should not hide an expected validation result.
The status you see may not be the status your code sent
Your handler picks a status, but it is not the last thing to touch the response. A reverse proxy or API gateway can rewrite a failed upstream reply. A serverless platform can wrap a crashed function in its own response. A catch-all error middleware, often generated along with the app, can catch everything thrown and answer 200 with a JSON error body. The code you are reading and the status on the wire can disagree.
The check is small: log the status at the point of return, then compare it against the status curl -i shows you. If they differ, the bug lives in the layer between them and no amount of editing the handler will fix it.
When 200 is the protocol, not the outcome
Some APIs use 200 as an envelope and put the real outcome in the body. GraphQL is the common one: an operation that fails at the field level usually still comes back as 200 with an errors array, so the status tells you the request was delivered and nothing else. Wrapper APIs do the same thing with a meta object:
{
"meta": { "code": 500, "message": "charge failed" },
"success": false,
"data": null
}
An envelope is fine when it is documented and every client reads the field. It stops being fine the moment something that is not your client reads the status and believes it: an uptime monitor, a log-based alert, a queue retry, a CDN. If you keep the envelope, alert on the field in the body, not on the status code.
A correct server response still needs a client check
JavaScript’s fetch() resolves its promise when an HTTP response arrives, including a response with 404 or 500. It rejects for failures such as a network error. MDN’s fetch reference therefore requires the client to check response.ok or response.status.
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(
errorBody?.code ?? `checkout_failed_${response.status}`,
);
}
const body = await response.json();
showConfirmation(body.orderId);
This check cannot repair a server that sends 200 on the failure branch. The server and client must agree: the handler returns a non-success status for the failed operation, and the client tests that status before showing confirmation.
A render error has a different boundary
A React component can throw while rendering because data has an unexpected shape:
<span>{user.profile.displayName}</span>
If profile is null, React removes the affected UI when no error boundary handles the render error. React’s error-boundary guidance explains the two jobs of a boundary: show a fallback and report the caught error.
An error boundary does not cover every browser failure. It does not catch ordinary event-handler errors, server-side rendering errors, asynchronous callback errors, or an error thrown inside the boundary itself. Report those at their execution boundary and keep promise rejections connected to the operation that started them.
Monitoring must exercise the contract you care about
A process can be reachable while checkout is broken. A route that always returns { ok: true } proves only that the web process can answer that route.
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.
Use separate signals for separate questions:
- A liveness check used for uptime monitoring answers whether the process is running. Keep it cheap and independent of optional services.
- A readiness or dependency check answers whether the instance can serve its required workload, such as reaching the database with a bounded timeout.
- A synthetic transaction exercises a critical flow with safe test data and verifies the business result, not just the status code.
- Exception monitoring groups unexpected errors and alerts on a meaningful threshold. Verify the alert reaches a person by triggering a known test exception.
- Business reconciliation compares states that must agree, such as successful payment events and granted entitlements.
The last item catches a class of failure that exception tracking cannot: code may run without throwing and still write the wrong state. Six ways a vibe-coded checkout leaks money covers the payment and entitlement boundary in detail.
None of this instrumentation is 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 stop swallowing and the renders that blow up in the browser. An UptimeRobot check that pings a real 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 cannot anticipate every way an app fails, so you instrument it to report whatever happens.
A failure you hear about from a dashboard costs you minutes. The same failure, reported by a customer, costs you the customer.
When a customer finds the bug before you do
A customer report is two findings, not one. The first is the broken path. The second, and the expensive one, is that the failure raised no signal of its own, so a stranger was doing your monitoring. Fix the path, then fix the silence.
The report never arrives as a request ID. It arrives as a sentence about paying on Tuesday and nothing happening. Turn it into one identifiable request before you guess at causes:
- The account or email they used, plus the time and their timezone. Those two narrow the server log to a handful of lines.
- The exact action: which page, which button, and what appeared afterwards.
- What they saw instead of the result, in their words, including any reference number the screen showed them.
- Whether the durable side effect exists: the row, the charge, the entitlement, the confirmation email. That settles whether the work happened, independently of what the response claimed.
Then read the access log line for that request beside the application log for the same request. Three outcomes, and each one says where the bug lives.
| What the logs show | What it means |
|---|---|
200 in the access log, nothing in the application log | The handler answered without doing the work, or an exception was caught and discarded on the way to the response. |
200 in the access log, an exception in the application log | Something between the return statement and the client rewrote the status, so read the proxy, gateway, or error middleware. |
| No matching request at all | The call never left the browser or never reached this service, and the failure sits in front of the handler. |
The second finding needs its own answer: which signal should have told you first. Match the failure to the one that would have caught it. A thrown error belongs to exception monitoring. A flow that returns success while writing nothing belongs to a synthetic transaction. A payment with no matching entitlement belongs to business reconciliation, because nothing was thrown for a monitor to catch. Ship the missing signal in the same change as the fix. Otherwise the next silent failure on that path reaches you the same way, from a customer who was patient enough to write in.
Repair one important path end to end
Choose signup, checkout, password reset, or the app’s main paid action. Then trace every exit from request to durable result.
- List every expected failure from validation, authorization, provider rejection, conflict, timeout, and internal error.
- Give each expected outcome a stable application error code and an appropriate HTTP status.
- Preserve unexpected exceptions in logs or traces with a request ID and deployment version, then return a sanitized response.
- Make the client check
response.okor the documented protocol envelope before it shows success. - Add an automated test for success and each important failure branch. Assert the status and the durable side effect.
- Trigger one controlled failure in a non-production environment and confirm the record, alert, client message, and cleanup behavior.
The automated test matters because a test file can stay green without executing the production handler. 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 historical corpus had zero working automated tests. For this repair, the useful proof is narrower and stronger: the test calls the same path the client calls and confirms that failed work cannot produce a success response.
Common questions about silent failures
What does it mean when an API returns 200 but no data?
It means the request reached the server and the server decided it had nothing to send back. Either the query genuinely matched nothing, or something removed the data first: a row level security policy, a stray filter, a missing or expired token that made the caller anonymous, wrong parameters, or a layer that rewrote a failed response into a success. The status line cannot tell those cases apart, so read the response body, the request headers, and the server log for that same request.
Why does my Supabase query return an empty array when the table has rows?
Row level security is one possible cause. If RLS is on and no policy matches the caller, the query can return data as an empty array with error set to null, which looks like an empty table. First compare the actual filter, project, session, and caller identity. Then repeat the check from a trusted server-side context that is authorized to see the rows; if they appear there, inspect the caller’s policy and session rather than treating RLS as established from the empty array alone.
Should an API return 404 or an empty array when there is no data?
For a collection, return 200 with an empty array. A search that matched nothing is a successful request, and an empty array lets every client parse the response the same way with or without results. Use 404 only when a single resource you addressed by id does not exist, and 204 No Content when the request succeeded and there is genuinely nothing to send back, such as a delete.
Why does my app show success when nothing was saved?
Something between the button and the database reported success without writing anything. Check for a database policy that rejected or filtered the write, an error that was caught and discarded so the handler continued to its success response, or a client that never checked the response before showing the confirmation. Open the network request first. If it came back 200 with an empty or generic body, follow that request ID into the server log.
Is it okay to return HTTP 200 with an error in the response body?
Only when the API’s documented protocol deliberately uses a success HTTP response as an envelope and every client interprets the application-level error field. For a conventional HTTP JSON endpoint, returning 200 for a failed operation makes generic clients, monitors, and intermediaries treat the response as successful. Use a non-2xx status that matches the cause.
What should an API return when a payment fails?
Use the payment provider’s outcome and your API contract. Invalid input, an unauthenticated caller, a declined payment, a conflict, a provider outage, and an internal exception are different failures. RFC 9110 reserves 402 Payment Required for future use, so do not choose it merely because the operation involved money.
How do I know whether users are seeing silent failures now?
Search for swallowed exceptions and success responses shared by both success and failure paths. Then compare server errors, client errors, synthetic transactions, and business reconciliation for the same time window. A gap between provider events and local state often exposes a silent failure that exception counts miss.
Is an empty catch block ever acceptable?
Yes, when the operation is explicitly best-effort and losing it cannot change the promised result. The code should document that decision. Analytics and cache warming can fit that category; payment, authentication, authorization, and data writes generally do not.
When every fix and release still depends on you
AxonBuild can trace the failure, repair the broken workflow, and ship the next change without rebuilding the parts that already work.