Swallowed Errors: Ten Reproductions of a Failure That Leaves No Trace
Ten small programs, each one producing a real error that disappears without a log, a stack trace or a non-zero exit code, with the measured output that proves it and the rule or line that finally made it visible.
Swallowed Errors: Ten Reproductions of a Failure That Leaves No Trace
Every example below is a file I ran on Node 22.23.2. The output pasted under each one is the real output, including the blank lines where a log should have been. An article about silent failure that only asserts things is the same bug it is describing, so nothing here is asserted — it is reproduced, and each entry ends with the thing that finally made the failure visible.
The subject is narrow: an error that happens, changes the outcome, and produces nothing you can search for. Not a bad error message. No error message.
1. The catch that answers with a failure and tells nobody
// 01-archetype.mjs
const logLines = [];
const logger = { error: (...a) => logLines.push(a) };
async function processPayment01() { throw new Error("gateway timeout after 30000ms"); }
async function checkout01(body) {
try {
await processPayment01(body);
return { success: true };
} catch (error) {
// TODO: handle this later
return { success: false, error: "Something went wrong. Please try again." };
}
}
console.log("returned:", await checkout01({ userId: "u_1" }));
console.log("log lines written:", logLines.length);returned: { success: false, error: 'Something went wrong. Please try again.' }
log lines written: 0The shape passes review because there is a catch and it does return something. What it discards is the only copy of the string gateway timeout after 30000ms that will ever exist. The caller learns that a thing failed; nobody learns which thing.
What made it visible: adding logger.error("checkout failed", { userId: body.userId, error }) before the return. One line, and the next incident starts with a searchable string instead of a bisect.
2. catch {} and catch (e) {} discard identically — but not to the linter
Optional catch binding (ES2019) lets you drop the parameter. Both forms below swallow:
// 02-binding.mjs
function noBinding02() { try { JSON.parse("{"); } catch {} return "returned normally"; }
function withBinding02() { try { JSON.parse("{"); } catch (e) {} return "returned normally"; }
console.log(noBinding02(), "|", withBinding02());returned normally | returned normallyRuntime-identical. The difference is downstream. I linted entry 1's handler under recommended-type-checked plus strict-type-checked, both with a binding and without: with catch (error) {, @typescript-eslint/no-unused-vars reports 'error' is defined but never used, which is the only rule in either preset that notices the swallow at all. Rewrite it as catch { and that report disappears, leaving the handler clean under every rule in both presets.
So optional catch binding is a small readability win that also deletes your last automated signal. Both forms are reported by no-empty — but only while the braces stay truly empty, which is the trap in the table further down.
3. return inside finally eats a live exception
This is the quietest one in the list, because the code looks like cleanup.
// 03-finally.mjs
function swallow03() {
try { throw new Error("BOOM"); }
finally { return "from finally"; }
}
function override03() {
try { return "from try"; }
finally { return "from finally"; }
}
function loopSwallow03() {
for (const x of [1]) {
try { throw new Error("BOOM2"); }
finally { break; }
}
return "loop finished";
}
console.log("swallow03() =", swallow03());
console.log("override03() =", override03());
console.log("loopSwallow03() =", loopSwallow03());swallow03() = from finally
override03() = from finally
loopSwallow03() = loop finishedThree separate behaviours in one run. A return in finally discards an in-flight throw entirely — no rethrow, no trace, the caller gets a string. It also beats a return that already ran in the try. And break does the same job as return: the loop exits, the error is gone.
What made it visible: ESLint's core no-unsafe-finally, which reports Unsafe usage of ReturnStatement on that line. It is not type-aware and costs nothing, and it is the only rule I found that catches this.
What does swallow03() return, and what happens to the Error('BOOM')?
4. A try/catch wrapped around a call you forgot to await
The catch block is real, typed, and unreachable.
// 04-noawait.mjs
async function deleteUser04() { throw new Error("FK constraint on sessions"); }
async function deleteAccount04() {
let caught = "nothing";
try {
const p = deleteUser04(); // no await
p.catch(() => {}); // only here so the process survives to print
} catch (e) { caught = e.message; }
return { caught, returned: { success: true } };
}
console.log(await deleteAccount04());
let caught04b = "nothing";
try { await deleteUser04(); } catch (e) { caught04b = e.message; }
console.log("with await:", caught04b);{ caught: 'nothing', returned: { success: true } }
with await: FK constraint on sessionsThe try block completes synchronously; the rejection arrives a microtask later, when no catch is on the stack. The function reports { success: true } for work that did not happen. The account is still there.
What made it visible: @typescript-eslint/no-floating-promises, which reported the un-awaited deleteUser04() with Promises must be awaited, end with a call to .catch, …. It needs type information, so listing it in a config is not enough — without typed linting configured it never runs.
5. .catch(() => {}) — deliberate, defensible, invisible
// 05-noop.mjs
const logLines05 = [];
async function identify05() { throw new Error("401 analytics token expired"); }
function syncToAnalytics05(userId) {
identify05(userId).catch(() => {}); // don't want this to break checkout
}
syncToAnalytics05("u_1");
await new Promise((r) => setTimeout(r, 10));
console.log("checkout continued. log lines:", logLines05.length);checkout continued. log lines: 0The intent is right — analytics must not take down checkout. The execution loses the fact that the token expired, which you will discover weeks later when someone asks why the funnel flattened. .catch((error) => logger.warn("analytics sync failed", { userId, error })) keeps the same control flow and costs nothing.
What made it visible: not no-floating-promises — a terminal .catch() satisfies that rule, and running it against a typed version of this file reported zero problems. @typescript-eslint/no-empty-function is what fires, with Unexpected empty arrow function pointing at the () => {}.
6. forEach with an async callback does not wait
// 06-foreach.mjs
const saved06 = [];
[1, 2, 3].forEach(async (id) => {
await new Promise((r) => setTimeout(r, 10));
saved06.push(id);
});
console.log("right after forEach, saved06 =", JSON.stringify(saved06));
const saved06b = [];
await Promise.all([1, 2, 3].map(async (id) => {
await new Promise((r) => setTimeout(r, 10));
saved06b.push(id);
}));
console.log("with map + Promise.all, saved06b =", JSON.stringify(saved06b));
setTimeout(() => console.log("50ms later, saved06 =", JSON.stringify(saved06)), 50);right after forEach, saved06 = []
with map + Promise.all, saved06b = [1,2,3]
50ms later, saved06 = [1,2,3]The work does happen — eventually. The problem is that anything reading the array on the next line sees an empty one, and in a serverless handler that returns before the 10ms elapses, the writes never land at all. Any rejection inside that callback is also unobservable, because forEach throws away the returned promise.
What made it visible: @typescript-eslint/no-misused-promises, with Promise returned in function argument where a void return was expected. no-floating-promises reported nothing for this file, which is worth internalising — the two rules split the work, and the typescript-eslint docs for no-misused-promises say so explicitly: that rule covers promises in logical positions, no-floating-promises covers statements. [1, 2, 3].forEach(async value => …) is a documented incorrect example for no-misused-promises.
You enable only @typescript-eslint/no-floating-promises. Which of these does it report?
7. The fire-and-forget that never fires
This one surprised me enough that I checked it twice. Prisma's query builders return a lazy thenable — no request is sent until something calls .then.
// 07-lazy.mjs — @prisma/client 6.19.2, DB URL pointed at a dead host
import { PrismaClient } from "@prisma/client";
const prisma07 = new PrismaClient({
datasources: { db: { url: "postgresql://nobody:nope@127.0.0.1:1/none" } },
});
const q07 = prisma07.blogPost.findFirst(); // no await
console.log("thenable:", typeof q07.then === "function");
await new Promise((r) => setTimeout(r, 1500));
console.log("1.5s later: no error, no rejection, process still at exit code 0");
try { await q07; } catch (e) { console.log("awaiting it throws:", e.constructor.name); }thenable: true
1.5s later: no error, no rejection, process still at exit code 0
awaiting it throws: PrismaClientInitializationErrorThe database host does not exist. An eager promise would have tried to connect and rejected within 1.5 seconds, and an unhandled rejection on Node 22 terminates the process (entry 8). Neither happened, because no query was ever issued.
So the advice "drop the await if you don't care about the result" is wrong for this client in a way that is worse than a floating rejection: there is no rejection to float, because there is no work. Writing prisma.auditLog.create({ ... }) without await does not write an unreliable audit row. It writes nothing, forever, and the only trace is the row that isn't there.
What made it visible: awaiting the same object I had already discarded, which immediately produced the connection error the non-awaited call had never attempted.
8. An unhandled rejection on Node 22 is loud — unless you make it quiet
// 08-unhandled.mjs
setTimeout(() => { Promise.reject(new Error("late floating rejection")); }, 5);
setTimeout(() => console.log("this line never runs"), 50);Error: late floating rejection
at Timeout._onTimeout (…/08-unhandled.mjs:2:35)
Node.js v22.23.2
exit=1A process.on("exit") hook in the same run reported exit event, code = 1. Default mode on Node 22 is throw: the process dies and the second timer never runs. That is the good case, and it is why the old "unhandled rejections are silent data loss" framing no longer holds — under --unhandled-rejections=warn I measured the legacy behaviour instead, a UnhandledPromiseRejectionWarning and exit=0, but that is opt-in now.
The silent version is the one people install on purpose:
// 08b-muzzle.mjs
process.on("unhandledRejection", () => {});
let saved08 = 0;
async function save08() { throw new Error("db down"); }
setTimeout(() => { save08(); }, 0);
setTimeout(() => console.log("rows saved:", saved08, "| exit code will be 0"), 30);rows saved: 0 | exit code will be 0One empty handler converts a crash-with-stack-trace into a green deploy that writes nothing.
9. uncaughtException keeps the process alive and the state wrong
Same trade, one level up, and the damage is worse because execution resumes mid-function.
// 09-uncaught.mjs
let balance09 = 100;
process.on("uncaughtException", (err) => { console.log("swallowed:", err.message); });
function withdraw09(n) {
balance09 -= n; // mutation happens first
if (n > 50) throw new Error("limit exceeded"); // validation happens second
return balance09;
}
setTimeout(() => { withdraw09(80); }, 0);
setTimeout(() => { console.log("balance09 =", balance09); }, 20);swallowed: limit exceeded
balance09 = 20Exit code 0. The withdrawal was rejected and the money left anyway. An uncaughtException handler cannot unwind the stack, so every partial mutation before the throw stays committed, and the process carries on serving requests against that state. If you install one, its body should log and then call process.exit(1).
10. The empty catch that hides a bug in your own handler
The error you catch is often not the error you are catching.
// 10-misattributed.mjs
function parseConfig10(raw) {
try { return JSON.parse(raw).port; }
catch { return 3000; } // "bad JSON, fall back"
}
console.log("valid, has port ->", parseConfig10('{"port":8080}'));
console.log("valid, no port ->", parseConfig10('{"name":"api"}'));
console.log("valid JSON `null` ->", parseConfig10("null"));valid, has port -> 8080
valid, no port -> undefined
valid JSON `null` -> 3000The third line is a TypeError — JSON.parse("null") succeeds and returns null, then .port on it throws. The catch was written for malformed JSON and it silently absorbs a null-safety bug in the expression next to it. The second line is worse: no error at all, just undefined escaping as a port number.
Give the block a binding and a log and the misattribution surfaces on the first run. Replacing the body with catch (e) { console.log("[handler] reason:", e.message.toUpperCase()); return 3000; } printed:
[handler] reason: UNEXPECTED END OF JSON INPUT
bad JSON -> 3000
[handler] reason: CANNOT READ PROPERTIES OF NULL (READING 'PORT')
null -> 3000Two completely different faults, one of them mine, previously indistinguishable. And that logging line is itself a demonstration of the same category: e.message.toUpperCase() assumes e is an Error. Feed the same handler a throw "nope" and it dies with TypeError: Cannot read properties of undefined (reading 'toUpperCase') — the recovery path becomes the failure, which is why error instanceof Error ? error.message : String(error) is worth the keystrokes.
Where an error can leave the path
What the linter measured
I ran ESLint 9.39.5 with typescript-eslint 8.70.0 over the ten files above, one rule at a time, under typed linting. The results, not the intentions:
| Pattern | Reported by | Not reported by |
|---|---|---|
catch (e) {} / catch {}, truly empty | no-empty | — |
catch (e) { /* comment */ } | no-unused-vars (on e) | no-empty, no-empty-function |
catch { /* comment */ } | nothing | no-empty, no-empty-function, both type-checked presets |
catch { return { success: false } } | nothing | every rule tried |
return inside finally | no-unsafe-finally | no-empty |
bare save(id);, no await | no-floating-promises | no-misused-promises |
save(id).catch(() => {}) | no-empty-function | no-floating-promises |
forEach(async …) | no-misused-promises | no-floating-promises |
Two rows deserve attention. no-empty ignores a block that contains a comment, so catch (e) { // email failed, oh well } — the most common swallow I see in real code — passes no-empty even with allowEmptyCatch: false set. I did not believe that until the run: with both no-empty and no-empty-function at error, the file reported zero problems, and it stayed at zero until I deleted the comment.
The returning catch is invisible to more than that. I linted it under recommended-type-checked and strict-type-checked together, plus no-empty, no-empty-function and no-unsafe-finally, and the only finding was the unused error binding from entry 2.
Which means the config usually copied for this problem — no-floating-promises plus no-empty with allowEmptyCatch: false — reported exactly two of these eight rows when I ran it over all of them. It misses the commented catch, the returning catch, the finally return, the noop .catch, and the async forEach. A pair that does better: no-misused-promises alongside no-floating-promises for the promise rows, and no-empty-function alongside no-empty for the discard rows, with no-unsafe-finally on because it is free.
Counting the shape in a codebase that ships
This site's own repository, 486 TypeScript files, measured with a brace-matching script over src/:
// count-swallows.mjs
import fs from "fs"; import path from "path";
const files = []; (function walk(d) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p); else if (/\.tsx?$/.test(e.name)) files.push(p);
}
})("src");
let total = 0, silent = 0;
for (const f of files) {
const s = fs.readFileSync(f, "utf8");
const re = /catch\s*(\([^)]*\))?\s*\{/g; let m;
while ((m = re.exec(s))) {
total++;
let i = re.lastIndex, depth = 1;
while (i < s.length && depth > 0) { if (s[i] === "{") depth++; else if (s[i] === "}") depth--; i++; }
const body = s.slice(re.lastIndex, i - 1);
if (!/console\.|logger\.|throw/.test(body)) silent++;
}
}
console.log({ files: files.length, total, silent });{ files: 486, total: 187, silent: 157 }157 of 187 catch blocks neither log nor rethrow, and they are not random: the dominant form is the server-action template, } catch { return { success: false, error: "Failed to create chapter. Please try again." }; }. The typed response contract is fine — a discriminated union with a user-facing string is exactly right for a form action. The discard next to it is not. Every one of those handlers could keep its return value and add a log above it, and the reason none of them do is that the repo's ESLint config extends eslint-config-next with no typed linting, so not one rule in the table above is switched on.
So here is the review question I now ask instead of "is the error handled": if this line throws at 3am on a Sunday, which string do I paste into the log search to find it — and if you cannot name the string, who told you the code worked?
Comments (0)
No comments yet. Be the first to share your thoughts!
Related Articles

