When an Integration Fails, Never Show Zero: The Silent Empty State

This one is three lines long and appears in nearly every codebase that talks to another system:
async function loadOpenInvoices(tenantId) {
try {
return await accounting.openInvoices(tenantId);
} catch (err) {
logger.error("failed to load invoices", err);
return []; // ← the whole bug
}
}
It's defensive code. It stops one failing integration from breaking a page. It was also, in our own portal, the reason an administrator could open a dashboard during a vendor outage and see $0 outstanding across every customer — stated as confidently as the real figure, with no indication that nothing had loaded.
Nobody was misled for long, because our review caught it. But the pattern deserves more attention than it gets, because the failure is invisible by design: the log line is in the log, the page renders, monitoring is green, and the number is wrong.
What's actually wrong with returning an empty list?
An empty list is an answer. "I couldn't reach the source" is not an answer. Collapsing the second into the first destroys the difference between nothing exists and we don't know, and every consumer downstream — the page, the report, the alert rule, the automation — treats the invented answer as fact.
The consequences follow the domain:
| Where it happens | What the failure renders as | What someone does about it |
|---|---|---|
| Accounting balances | $0 owed | Nobody chases an invoice |
| Device or asset inventory | No devices | A site looks decommissioned |
| Security alerts | No alerts | The quietest day on record |
| Patch or backup status | Nothing overdue | A compliance report says all clear |
| Usage or billing counters | Zero usage | An invoice goes out short |
The security and backup rows are the ones worth dwelling on. An empty alert list looks exactly like a good day, so this pattern doesn't merely hide an outage: it produces the most reassuring possible screen at the moment it's least true.
The three states of any external read
Every read from a system you don't control lands in one of three states. Model them explicitly and the rest of the design follows:
- OK — the source answered. The data is current.
- STALE — the source failed, but you have a recent answer. Show it, say how old it is.
- UNAVAILABLE — the source failed and you have nothing recent enough to stand behind. Show that, and don't show a number.
The mistake in the code above isn't the catch. It's that a three-state reality is being squeezed into
a one-state return type. Once the function can only return an array, the failure has nowhere to go
except into the data.
How to fix it
1. Change the return type, not the error handling. A function that reads from an external system should return a result with a status, not a bare value. The type change is what makes every caller handle it, including the ones written next year.
2. Serve stale data on purpose, with its age. Most integration outages are minutes long. A figure from eleven minutes ago, labelled as such, is far more useful than an error page — and far more honest than a fresh-looking zero. Decide how old is too old per data type: an account balance ages gracefully, a security alert feed does not.
3. Say "unavailable" in the UI, not "0". Blank the figure, show a short note, and keep it visually distinct from a real value. The one thing the note must do is rule out the reading people would otherwise make: this is not zero, we couldn't load it.
4. Give every external read a timeout. Without one, a hung vendor doesn't just fail — it holds your request handler, your connection pool and eventually your page.
5. Alert on the failure, don't just log it. If a read fails every cycle for an hour, that's an outage, and log lines are not a monitoring system. Count consecutive failures per integration and page someone at a threshold.
6. Never write a failure into your cache. Overwriting a good answer with an empty one turns a transient outage into a permanent wrong number — the same shape of bug as the payment webhook that claimed success before it reconciled.
The reader
This wraps any read and reports which of the three states happened. It works with whatever cache you already have — an in-memory map, Redis, a database table — and adds a timeout and single-flight so a slow source can't be hammered by every concurrent request.
// resilient-read.mjs: read from an external system without letting a failure look like real data.
// Node 18+, no dependencies.
export const OK = "ok"; // fresh data from the source
export const STALE = "stale"; // the source failed; this is the last good answer
export const UNAVAILABLE = "unavailable"; // the source failed and there is nothing trustworthy to show
class Timeout extends Error {
constructor(ms) {
super(`timed out after ${ms}ms`);
this.name = "TimeoutError";
}
}
async function withTimeout(load, ms, signal) {
if (!ms) return load({ signal });
let timer;
try {
return await Promise.race([
load({ signal }),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Timeout(ms)), ms); }),
]);
} finally {
clearTimeout(timer);
}
}
/**
* Reads a value from an external system and always reports which of three things happened.
* It never substitutes an empty list or a zero for an answer it didn't get.
*
* store: { get(key), set(key, { data, fetchedAt }) } — any cache you already have.
* maxStaleMs: how old a cached answer may be before it stops being worth showing.
*/
export function createReader({ store, maxStaleMs = 15 * 60_000, timeoutMs = 10_000, now = Date.now }) {
const inFlight = new Map(); // one request per key, however many callers ask at once
return async function read(key, load) {
if (inFlight.has(key)) return inFlight.get(key); // share the in-flight request, don't stampede
const attempt = (async () => {
try {
const data = await withTimeout(load, timeoutMs);
const fetchedAt = now();
await store.set(key, { data, fetchedAt });
return { status: OK, data, fetchedAt, ageMs: 0 };
} catch (err) {
const cached = await store.get(key);
const error = { message: err?.message ?? String(err), name: err?.name ?? "Error" };
if (cached) {
const ageMs = now() - cached.fetchedAt;
if (ageMs <= maxStaleMs) {
return { status: STALE, data: cached.data, fetchedAt: cached.fetchedAt, ageMs, error };
}
return { status: UNAVAILABLE, ageMs, fetchedAt: cached.fetchedAt, error };
}
return { status: UNAVAILABLE, error };
} finally {
inFlight.delete(key);
}
})();
inFlight.set(key, attempt);
return attempt;
};
}
/** True when a result must not be presented as a real figure. */
export function isUnavailable(result) {
return result?.status === UNAVAILABLE;
}
/** What to show a person, given a result. Keeps "we don't know" out of the numbers. */
export function describe(result, { label = "This figure" } = {}) {
if (result?.status === OK) return { show: true, note: null };
if (result?.status === STALE) {
return { show: true, note: `${label} was last updated ${formatAge(result.ageMs)} ago — we can't reach the source right now.` };
}
return { show: false, note: `${label} is unavailable right now. It is not zero — we couldn't load it.` };
}
function formatAge(ms) {
const minutes = Math.floor(ms / 60_000);
if (minutes < 1) return "less than a minute";
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? "" : "s"}`;
}
Using it
const read = createReader({ store: redisStore, maxStaleMs: 15 * 60_000, timeoutMs: 8_000 });
const result = await read(`ar:${tenantId}`, () => accounting.openInvoices(tenantId));
const view = describe(result, { label: "Amount owed" });
return {
amount: view.show ? sum(result.data) : null, // null, never 0
note: view.note, // "…last updated 6 minutes ago" / "…is unavailable"
degraded: result.status !== "ok",
};
The thing to notice is what the caller can no longer do by accident: there's no path where a failure
produces a number. result.data doesn't exist in the unavailable case, so sum(result.data) can't
quietly return 0.
How to find this in your own system
Search for the pattern. It's recognizable:
rg -n --type ts 'catch[^)]*\)?\s*\{[^}]*return (\[\]|\{\}|0|null)' -U
rg -n --type ts 'catch' -A 3 | rg -B 2 'return \[\]|return 0|\?\? \[\]|\|\| \[\]'
Then ask of each hit: if this fails, does a person see a number they'll believe? Empty-on-failure is fine for a decorative widget and dangerous for anything anyone acts on.
Test it the way a customer would meet it. Point the integration at an unroutable address or a credential that's been revoked, then look at every page that uses it. You are looking for confident zeros, empty tables that say "no results", and green status indicators. This takes about ten minutes per integration and is the most valuable ten minutes in this article.
Check what your cache does during the outage. Some caching layers happily store the empty result, so the wrong answer outlives the outage. Ours writes only on success, which is one of the things the tests below pin down.
How we tested it
37 tests cover each state and the boundaries between them: a successful read caches and reports age
zero; a failure with no cache returns unavailable with no data and never an empty array; a failure
with a recent cached answer returns stale with its age and the error alongside it; an answer past the
staleness limit is withheld, with the boundary inclusive at exactly the limit; a hung source times out
promptly and says so; concurrent callers for one key share a single request while different keys stay
separate; a failure never overwrites the cache; and an empty list or a zero from the source is still
reported as a real answer, because sometimes the answer really is nothing.
Then the tests were checked against the module: weakening any of 18 details — returning [] on
failure, reporting stale data as fresh, dropping the staleness limit, caching failures, removing the
timeout, showing an unavailable figure as data — makes the suite fail. The first two mutants are the
original bug, so the suite fails if anyone reintroduces it.
Frequently asked questions
Isn't this over-engineering a try/catch?
The three-state result is about ten lines. The alternative isn't simpler code, it's a number nobody can trust, and an incident where the question "was that real?" has no answer. Use it for reads people act on — money, inventory, security, compliance — and keep the plain version for the rest.
What should the page actually show?
For stale: the value, dimmed or badged, with "as of 14:32" or "updated 6 minutes ago". For unavailable: an em dash or "unavailable" where the number would be, the reason in plain words, and a retry. Never a spinner that resolves to zero, and never a toast that disappears before anyone reads it.
How long should stale data be served?
Long enough to ride out an ordinary blip, short enough that nobody makes a decision on stale figures. Fifteen minutes suits balances and inventory; a minute or two suits anything live. Set it per data type, and remember that the label carries the honesty, not the number.
Does an empty result from the source ever matter?
Yes, and that's the point of keeping them apart: "no open invoices" is genuinely useful. The reader
returns that as ok with an empty array, because the source said so.
What about GraphQL or APIs that return partial data?
Same rule, one level down. If a response contains errors alongside partial data, the partial fields are unavailable, not empty. Map them to the same three states rather than letting a null field become a zero in a total.
How does this interact with monitoring?
It feeds it. Because the reader knows the difference between a real empty answer and a failure, you can count failures per integration and alert on a streak. That's a much better signal than "the API returned non-200", since it measures what your application actually experienced.
Related reading
This came out of the same review as credentials leaking into logs, two payment bugs and privilege escalation through role assignment. If you want someone watching your integrations, and the dashboards built on them, that's what our managed network operations and monitoring services do.


