Fail Loudly, Recover Quietly
One of ten attitude adjustments from Halcyon Compute. Read this if you have ever wrapped something in
try { ... } catch { return null }and considered the matter closed.
The rule
Errors that you cannot meaningfully handle should propagate, with their original message and stack trace intact. Errors that you can meaningfully handle should be handled in exactly one place, as close to the source as possible.
Silently swallowing an error to "keep things working" is not handling. It is hiding. The system is now broken AND lying about it, which is strictly worse than being broken honestly.
The three patterns to never write again
Pattern 1: The empty catch
try {
await doTheThing();
} catch (e) {
// ignore
}
You have just told the rest of the program that doTheThing() succeeded. It did not. Whatever depended on it being done will now fail in a more confusing place, with a less informative error, possibly hours later. You have not prevented a bug. You have moved it.
Pattern 2: The lying fallback
async function getUser(id: string) {
try {
return await db.users.findById(id);
} catch {
return null;
}
}
The caller now cannot tell the difference between "user does not exist" and "the database is on fire". They will treat both as "user does not exist" and the system will quietly behave as if every user has been deleted. This is how outages become data losses.
If you want to express "either the user, or absent", make absent the explicit successful case (return null when the row isn't there) and let actual errors propagate. Two outcomes, two paths.
Pattern 3: The console.log apology
try {
await sendInvoice(customer);
} catch (e) {
console.error("Failed to send invoice", e);
}
The customer was not charged, was not notified, and the surrounding code thinks the invoice was sent. Nobody reads the console. Six months later finance asks why revenue is down. You did this.
What to do instead
For each error, decide which of three things is true:
- I can recover from this AND continue meaningfully. Recover, log at warning level, continue. The user-visible behaviour is correct.
- I cannot recover, but I can degrade. Return a typed result that the caller is forced to handle (
{ ok: false, reason }or similar). The caller knows the operation didn't succeed. - I cannot do anything useful with this. Let it propagate. Do not catch. The error reaches the boundary of the system (request handler, queue worker, top-level loop) where SOMETHING is in a position to decide what the user should see.
If you don't know which of the three you're in, you're in the third one. Don't catch.
The boundary catch
There should be exactly one place per request/job/loop where errors are caught and turned into user-facing responses. That boundary:
- Logs the full error with enough context to reproduce.
- Returns a sanitised message to the user (no stack traces, no internal paths).
- Increments a metric.
- Returns the appropriate HTTP status code or job-failure signal.
Inside that boundary, errors propagate freely. Outside it, they become structured outputs.
The exception that proves the rule
There is exactly one good reason to catch and continue: when you genuinely have multiple independent things to do and one failing should not stop the others. ("Send notifications to N users; if user 17 fails, keep sending to users 18..N and report the failures at the end.")
Even then: log every failure with the full error, accumulate the failures, and surface them at the boundary. Do not return success when half the work failed.
The line to remember
A loud error is a free debugging session for whoever finds it. A swallowed error is a debt that compounds until someone has to do an outage to repay it.
— Halcyon Compute, attitude adjustment 07 of 10