Returning an empty list on failure is a lie your users will believe
Abbas
August 3, 2026
The inherited convention in this codebase was the most common error-handling pattern in the world:
try {
return searchProviders(query);
} catch (Exception e) {
log.error("search failed", e);
return new ArrayList<>(); // don't blow up the client
}
It looks defensive and considerate. It is neither.
What the user sees
When the database is unreachable, that code returns an empty list with 200 OK. The Android app renders exactly what it was designed to render for an empty list:
No providers near you.
That is a confident, specific, false statement about the world. The user believes there are no plumbers in their suburb. They close the app. Nothing has crashed, so nothing pages anyone, and the dashboard shows a perfectly healthy 200 response rate.
An outage disguised as an answer is worse than an outage. A crash gets fixed on the same day; this gets fixed when someone eventually wonders why searches in a whole category returned nothing for a week.
The fix, and the part that surprised me
The change is to fail honestly β return 503 when the failure is ours:
export const withHandler = (name: string, fn: Handler): Handler =>
async (req, res) => {
try {
return await fn(req, res);
} catch (err) {
log.error({ name, err });
return res.status(503).json({ error: 'service_unavailable', endpoint: name });
}
};
The obvious worry: the Android client is inherited code with its own release cycle, and it expects a JSON array from this endpoint. Changing the server usually means shipping a client to match, and an unreleased client is a slow, awkward dependency.
It turned out not to matter β for a structural reason worth stealing.
The error response is a JSON object. The client's Gson layer is asking for a List. An object cannot be parsed into a list, so deserialisation throws β and every ViewModel in the app already had an exception branch, because network calls have always been able to fail.
So the failure landed in the existing error path automatically. The app shows its real error state instead of a fake empty state, and not a single line of client code shipped.
The general principle
Make the shape of your error response structurally incompatible with the shape of your success response.
If success is an array, let errors be an object. If success is an object with data, don't return a bare { error } at the same level. Clients that assume the happy path will then fail loudly at the parse boundary rather than silently interpreting your failure as a legitimate empty result.
The rule underneath it: never let "we broke" and "there is nothing" produce the same bytes. Those are completely different facts, and only one of them is safe to show a user as an answer.
Commit 2d7c8e2 in Insta Services.