Error Tracking for Capacitor and Ionic Apps: Web Errors, Native Plugins, and WebView Crashes Together
Catch JS errors, plugin failures, and WebView crashes in one dashboard for Capacitor apps.
Last month I watched a support ticket sit in limbo for three days because nobody could reproduce the crash. The user's Capacitor app died on launch — but only on Android 12, only on Samsung devices, and only when the camera plugin initialized before the filesystem plugin. The stack trace pointed at JavaScript, the native logs pointed at a WKWebView equivalent on Android, and the Sentry dashboard showed nothing useful because it only caught the surface-level unhandled rejection.
So I spent a weekend rebuilding the error tracking setup to catch the full picture: JavaScript errors, native plugin bridge failures, and WebView crashes — all landing in the same dashboard with correlated context. This tutorial walks through that setup using JustAnalytics, but the patterns work with any error tracking tool that accepts structured payloads.
What you'll have by the end
A Capacitor/Ionic app with:
- Global JavaScript error boundaries that catch uncaught exceptions and unhandled promise rejections
- Native plugin error wrapping that captures bridge failures with plugin name and method context
- WebView crash detection using Capacitor's App plugin lifecycle hooks
- All three error types landing in one dashboard with device info, app version, and platform context
The setup adds about 40 lines of code across three files. You'll stop guessing which layer broke. (Finally.)
Prerequisites
- Capacitor 5.0+ or 6.0+ (tested on 6.2)
- Ionic 7+ (or vanilla Capacitor without Ionic — the patterns work either way)
- A JustAnalytics account — free tier handles 100K events/month
npm install @capacitor/appif you want lifecycle crash detection- Basic familiarity with Capacitor's plugin bridge
(If you're on React Native or Flutter instead of Capacitor, see our WebView hybrid monitoring guide for the equivalent patterns.)
Step 1: Set up the base error tracking script
Create a new file at src/services/error-tracking.ts. This is the core that everything else hooks into:
// src/services/error-tracking.ts
import { Capacitor } from '@capacitor/core';
interface ErrorPayload {
event: 'error' | 'unhandled_rejection' | 'plugin_error' | 'webview_crash';
message: string;
stack?: string;
context: {
platform: string;
appVersion: string;
osVersion?: string;
deviceModel?: string;
pluginName?: string;
pluginMethod?: string;
url?: string;
};
}
const API_ENDPOINT = 'https://api.justanalytics.app/v1/events';
const API_KEY = import.meta.env.VITE_JUSTANALYTICS_API_KEY;
const SITE_ID = import.meta.env.VITE_JUSTANALYTICS_SITE_ID;
export async function trackError(payload: ErrorPayload): Promise<void> {
if (!API_KEY) return;
try {
await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
site_id: SITE_ID,
event: payload.event,
properties: {
message: payload.message,
stack: payload.stack?.slice(0, 5000),
...payload.context,
},
}),
});
} catch {
// fail silently — don't break the app for error tracking
}
}
export function getDeviceContext(): ErrorPayload['context'] {
return {
platform: Capacitor.getPlatform(),
appVersion: import.meta.env.VITE_APP_VERSION || 'unknown',
url: window.location.href,
};
}
The slice(0, 5000) on stack traces keeps payloads reasonable. I've seen Capacitor stack traces with plugin bridge frames balloon to 15KB. Nobody needs that. (I learned this the hard way when our event ingestion started timing out.)
Step 2: Wire up global JavaScript error handlers
Now add the global handlers. In your app's entry point (usually src/main.ts or src/main.tsx), add:
// src/main.ts (or wherever your app bootstraps)
import { trackError, getDeviceContext } from './services/error-tracking';
// Catch synchronous errors
window.onerror = (message, source, lineno, colno, error) => {
trackError({
event: 'error',
message: typeof message === 'string' ? message : 'Unknown error',
stack: error?.stack,
context: {
...getDeviceContext(),
url: source || window.location.href,
},
});
};
// Catch unhandled promise rejections
window.onunhandledrejection = (event) => {
const error = event.reason;
trackError({
event: 'unhandled_rejection',
message: error?.message || String(error) || 'Unhandled promise rejection',
stack: error?.stack,
context: getDeviceContext(),
});
};
This catches the standard web errors. But Capacitor apps have another failure mode: the native plugin bridge.
Step 3: Wrap native plugin calls
Native plugin failures are the sneaky ones. The JavaScript layer sees a rejected promise, but the context about which plugin and which method failed often gets lost. Here's a wrapper that preserves that context:
// src/services/plugin-wrapper.ts
import { trackError, getDeviceContext } from './error-tracking';
export function wrapPlugin<T extends Record<string, (...args: any[]) => Promise<any>>>(
plugin: T,
pluginName: string
): T {
const wrapped = {} as T;
for (const key of Object.keys(plugin) as Array<keyof T>) {
const original = plugin[key];
if (typeof original === 'function') {
(wrapped as any)[key] = async (...args: any[]) => {
try {
return await original.apply(plugin, args);
} catch (error: any) {
trackError({
event: 'plugin_error',
message: error?.message || `Plugin ${pluginName}.${String(key)} failed`,
stack: error?.stack,
context: {
...getDeviceContext(),
pluginName,
pluginMethod: String(key),
},
});
throw error; // re-throw so the app can handle it
}
};
}
}
return wrapped;
}
Use it like this:
// src/services/camera.ts
import { Camera } from '@capacitor/camera';
import { wrapPlugin } from './plugin-wrapper';
export const TrackedCamera = wrapPlugin(Camera, 'Camera');
// Then in your components:
// import { TrackedCamera } from '@/services/camera';
// const photo = await TrackedCamera.getPhoto({ ... });
Every plugin call now reports failures with the plugin name and method. When that camera permission denial happens on Samsung S22 devices running Android 12, you'll see Camera.getPhoto in your dashboard — not just "Unhandled rejection: NotAllowedError."
One thing I'll admit: wrapping every plugin adds overhead. Try/catch per call. Sounds bad, right? In practice we measured under 0.1ms per call — undetectable. The debugging time you'll save is absurdly worth it.
Step 4: Detect WebView lifecycle crashes
Sometimes the WebView itself dies. The app doesn't crash in the native sense, but the web context gets terminated and restarted. Capacitor's App plugin exposes lifecycle hooks that let you detect this:
// src/services/lifecycle-tracking.ts
import { App } from '@capacitor/app';
import { trackError, getDeviceContext } from './error-tracking';
let wasBackgrounded = false;
let lastActiveTimestamp = Date.now();
export function setupLifecycleTracking(): void {
App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) {
wasBackgrounded = true;
lastActiveTimestamp = Date.now();
} else if (wasBackgrounded) {
const backgroundDuration = Date.now() - lastActiveTimestamp;
// If we were backgrounded for under 100ms but the state changed,
// the WebView likely crashed and restarted
if (backgroundDuration < 100) {
trackError({
event: 'webview_crash',
message: 'WebView terminated and restarted (suspected OOM or crash)',
context: {
...getDeviceContext(),
backgroundDuration: String(backgroundDuration),
},
});
}
wasBackgrounded = false;
}
});
App.addListener('backButton', () => {
// Track back button as context — helps debug "app closed unexpectedly" reports
console.debug('[Lifecycle] Back button pressed');
});
}
Call setupLifecycleTracking() early in your app bootstrap. The 100ms threshold is a heuristic — real user backgrounding takes longer than that. A sub-100ms cycle usually means the OS killed the WebView process and Capacitor restarted it.
On iOS, WKWebView is aggressive about memory reclamation. Borderline rude, if I'm honest. On Android, the system WebView can crash on devices with low RAM or aggressive battery optimization. Both scenarios look identical to users ("the app glitched") but have different root causes. This detection helps you separate them.
Step 5: Add framework-specific error boundaries
If you're using Angular (common with Ionic), add an error handler:
// src/app/error-handler.ts (Angular)
import { ErrorHandler, Injectable } from '@angular/core';
import { trackError, getDeviceContext } from '../services/error-tracking';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error: any): void {
trackError({
event: 'error',
message: error?.message || 'Angular error boundary caught an error',
stack: error?.stack,
context: getDeviceContext(),
});
// Still log to console for local debugging
console.error('Global error:', error);
}
}
Register it in your app module:
// src/app/app.module.ts
import { ErrorHandler, NgModule } from '@angular/core';
import { GlobalErrorHandler } from './error-handler';
@NgModule({
providers: [{ provide: ErrorHandler, useClass: GlobalErrorHandler }],
// ... rest of config
})
export class AppModule {}
For React apps (using Capacitor without Ionic, or with Ionic React), use the standard error boundary pattern — we've covered that in our Django middleware tutorial under the "framework-specific" section.
Common errors and how to fix them
Look, I've made every mistake here. So you don't have to.
Plugin errors show "undefined is not an object" with no useful stack. Some plugins catch errors internally and return undefined or null instead of throwing. The wrapper won't help here — you need to check the plugin's return value explicitly. The Capacitor Filesystem plugin does this on permission denial in some versions.
WebView crash detection fires on every app resume. Your threshold is too high. Lower it to 50ms or check if document.hidden is reliable in your WebView version. Some Android WebViews report state changes inconsistently.
Errors fire in dev but not in production builds. Check that your environment variables (VITE_JUSTANALYTICS_API_KEY, VITE_JUSTANALYTICS_SITE_ID) are set in your production build config. Capacitor's build process doesn't automatically inject .env variables — you need vite-plugin-environment or a manual define in your vite.config.ts.
Stack traces are minified garbage. Upload source maps during your build step. JustAnalytics accepts source maps via the API — add a post-build script that sends them. Our source map deobfuscation guide walks through the CI integration. Without source maps, you'll see line numbers like "main.js:1:48293" which tells you nothing.
Same error fires hundreds of times. Add a deduplication layer. Track the last 10 error hashes in memory and skip duplicates within a 60-second window. I didn't include this in the base setup because it adds complexity, but at scale you'll want it. Trust me — I burned through a month's event quota in four hours once. Not fun.
What this won't fix
Pure native crashes — Swift exceptions, Kotlin crashes, NDK segfaults — don't surface to JavaScript. If your native code dies before the bridge can report it, you'll need Firebase Crashlytics or a similar native SDK alongside this setup. Most Capacitor apps are 95% JavaScript, so you'll catch most issues. But that 5% native layer can still bite you.
This also won't help with network failures. API returns a 500? That's not a JavaScript exception unless you throw one. Bit annoying, honestly — I keep wishing these tools would merge the concepts. You'll want separate monitoring for API health. We cover that in our uptime monitoring setup guide.
And if your app is slow but not broken, error tracking won't surface that. APM and performance monitoring are separate concerns. JustAnalytics bundles APM with error tracking in the same dashboard, but they're different instrumentation.
Next steps
You've got unified error tracking across JavaScript, native plugins, and WebView lifecycle. The obvious follow-up is adding session replay so you can see what the user was doing when the error happened — session replay for SaaS onboarding covers the privacy masking setup, which matters on mobile where users input sensitive data.
For teams building cross-platform apps, DevOS helps manage the multi-repo complexity of Capacitor projects. If you're running paid acquisition for your mobile app, ClickzProtect catches fraudulent installs that would otherwise pollute your analytics. And for browser automation testing of your Capacitor app's web layer, JustBrowser handles the profile isolation you'll need for CI.
The full code from this tutorial is on GitHub at justanalytics/examples/capacitor-error-tracking. Fork it, break it, ship it.
Frequently Asked Questions
Does this work with Cordova plugins running under Capacitor's compatibility layer?
Yes. Capacitor's Cordova compatibility layer wraps plugin calls the same way native Capacitor plugins do, so the error boundary catches failures from both. The stack trace will show the Cordova plugin name in the call chain. One gotcha: some older Cordova plugins swallow errors internally and never reject the promise — for those, you'll need to patch the plugin or check its source. We've hit this with a few camera plugins that return undefined instead of throwing on permission denial.
How do I separate errors by platform (iOS vs Android) in the dashboard?
The error tracking payload includes a platform property that pulls from Capacitor.getPlatform(). Filter by platform in the JustAnalytics dashboard using the custom property filter. You can also set up separate alert rules — one for iOS, one for Android — if your team has dedicated mobile engineers per platform. We've found iOS WKWebView errors and Android WebView errors cluster differently, so platform-specific views help during triage.
Will this catch errors from native code that doesn't go through JavaScript?
No. This setup catches JavaScript errors, native plugin bridge failures (which surface as rejected promises in JS), and WebView crashes that the JavaScript runtime can observe. Pure native crashes — like a Swift exception in iOS or a Kotlin crash in Android — need platform-native crash reporting (Firebase Crashlytics, Bugsnag native SDK, etc.). Most Capacitor apps are 90%+ JavaScript, so this covers the bulk of production issues.
Can I correlate errors with session replay on mobile?
Session replay works in Capacitor apps because the UI is rendered in a WebView — it's still DOM-based. The replay SDK hooks into the same rrweb-style recording as web apps. JustAnalytics links error events to replay sessions automatically via the session ID. One limitation: if the app crashes hard enough that the WebView process terminates, the replay buffer may not flush. For soft errors (unhandled promise rejections, uncaught exceptions), replay correlation works the same as on web.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.