Read the SDK source. Third-party libraries feel like black boxes until you open them — the production issue costing you thousands of crashes may sit in 40 lines of code nobody on the team has ever read.
console.log(object) in a mobile WebView is structurally dangerous, not stylistic. Prefer console.log('saved', { id, status }); avoid console.log(response) / console.log(record) / console.log(err).
Defense in depth wins. Any one layer (build-strip, runtime guard, agent rule, breadcrumbs) can regress. All of them together stay resilient.
On low-RAM Android (TECNO/Vivo/Infinix) the WebView heap tops out around ~192 MB. Treat that as a hard ceiling for any data that crosses the JS-native bridge.
What I blamed first (and got wrong)
Two days, three wrong hypotheses:
- Image handling — overzealous bitmap caching.
- Memory leaks — long-lived references in a Dexie subscriber.
- IndexedDB / Dexie — too-large records pulled into memory.
Every assumption was wrong. The crashes all tried to allocate the same 266 MB block, and the only signal connecting them was the device class — TECNO, Vivo, Infinix. Nothing in our JS code path touched that much memory.
What cracked it open
I stopped guessing and read the Capacitor source. The bridge runs Java's String.format on every console.log message before any log-level or production guard kicks in. So when JS does:
console.log(response);…and response is a large Dexie object with embedded base64 audio, Java tries to materialize the entire thing across the JS↔native bridge. On low-RAM devices the WebView heap caps around 192 MB. The 266 MB allocation goes through String.format, OOMs, and the app dies instantly.
The whole story was sitting in ~40 lines of Java that nobody on the team had ever opened.
The fix
Four layers, intentionally:
// Runtime guard installed at app entry, before any feature code runs.
const MAX_ARG_BYTES = 8 * 1024;
const truncate = (arg: unknown): unknown => {
if (typeof arg !== "string") {
try {
arg = JSON.stringify(arg);
} catch {
return "[unserializable]";
}
}
const s = arg as string;
if (s.length <= MAX_ARG_BYTES) return s;
return `${s.slice(0, MAX_ARG_BYTES)} … TRUNCATED ${s.length - MAX_ARG_BYTES} chars`;
};
for (const level of ["log", "info", "warn", "error", "debug"] as const) {
const original = console[level].bind(console);
console[level] = (...args: unknown[]) => original(...args.map(truncate));
}