Error Tracking for Capacitor and Ionic Apps: Web Errors, Native Plugins, and WebView Crashes Together
EngineeringSeptember 8, 202611 min read

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.

Consider a support ticket that sits for days because nobody can reproduce the crash. The app dies on launch, but only on certain Android devices and only when one plugin initialises before another. The stack trace points at JavaScript, the native logs point at the WebView, and an error tracker that only catches unhandled rejections shows nothing useful.

Catching the full picture means tracking JavaScript errors, native plugin bridge failures, and WebView crashes together, 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/app if 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. Plugin bridge frames can balloon stack traces to 15KB. Nobody needs that.

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? The overhead per call is small. 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

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. It's left out of the base setup because it adds complexity, but at scale you'll want it. A runaway loop can burn a month's event quota in hours.

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.

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. Some camera plugins 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. 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).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts