AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Capacitor & Cordova Crash Debugging: Hybrid App Guide

NFNourin Mahfuj Finick··9 min read

Hybrid mobile apps built with Capacitor and Cordova feel native, but when they crash, the failure rarely looks like a native crash. A JavaScript exception thrown inside a WebView, a rejected promise that never reaches a handler, or a plugin call that marshals the wrong type across the native bridge all produce crash reports that look nothing like the SIGSEGV dumps native engineers are used to. This guide covers capacitor crash reporting and cordova crash debugging end to end: how to instrument the JavaScript-to-native plugin bridge, decode WebView stack traces, map minified bundles back to source, and catch the platform-specific failures that only surface inside hybrid shells.

The stakes are real. Hybrid frameworks power a large and growing share of App Store and Google Play releases, and a single uncaught JavaScript error in an Ionic or Capacitor app can take down the entire experience as quickly as any native exception. Yet most crash reporting pipelines were built for native binaries, not for the layered WebView-plus-native architecture these frameworks ship to production. Before you can fix hybrid crashes, you have to understand where they originate — and why your existing tooling keeps missing them.

Why Hybrid Crashes Break Traditional Crash Reporting

A Capacitor or Cordova app is two runtimes glued together. One is a JavaScript engine running inside a system WebView — WKWebView on iOS and Android WebView on Android. The other is a native runtime — Swift or Objective-C on iOS, Kotlin or Java on Android — that owns the app's lifecycle, plugins, and any custom native code. When something goes wrong, the failure can land on either side of that boundary, and the two sides report errors completely differently.

Native crashes are delivered to the operating system as signals and exceptions: SIGSEGV, SIGABRT, NSException, RuntimeException. JavaScript errors, by contrast, are just events inside the WebView. If nothing listens for them, they vanish silently — the WebView logs a console message, the user stares at a frozen or blank screen, and no crash report is ever generated. This is the single biggest reason hybrid apps "crash" without leaving a trace: the native layer never learns that the JavaScript layer died.

The plugin bridge makes this worse. Capacitor and Cordova both expose native functionality to JavaScript through a serialized message boundary. When JavaScript calls a plugin, arguments are marshaled, handed to native code, and the result is marshaled back. A type mismatch, a null argument, a plugin that throws on the native side, or a callback that fires after the WebView has been torn down all create failures that span both runtimes. Neither a pure JavaScript error tracker nor a pure native crash reporter captures the full picture on its own.

The Plugin Bridge: Where JS Exceptions Become Native Faults

Most hybrid crashes that developers actually chase originate in a plugin call. In Capacitor, a call looks like this:

import { Capacitor } from '@capacitor/core';
 
async function scanBarcode() {
  try {
    const { value } = await Capacitor.Plugins.BarcodeScanner.scan();
    return value;
  } catch (err) {
    // err is a CapacitorException: code, message, and native stack
    console.error('scan failed', err);
    throw err;
  }
}

The try/catch matters because Capacitor rejects the promise with a CapacitorException when the native side fails. If you await a plugin call without a catch block, the rejection becomes an unhandledrejection — and depending on your setup, it may be swallowed entirely. Wrap every plugin call in an explicit handler and attach structured context (which plugin, which arguments, which user flow) so the failure is traceable later.

Cordova's equivalent is callback-based, and it has its own trap: the callback fires on a different tick, often after the page or view has already changed.

cordova.plugins.barcodeScanner.scan(
  function (result) { /* success */ },
  function (error) {
    // only fires if the plugin actually invokes the error callback
    console.error('scan failed', error);
  }
);

If a Cordova plugin throws on the native side instead of invoking the error callback, the JavaScript never finds out. That is why always-defined error callbacks — and a global safety net — are non-negotiable in Cordova apps. The same lesson applies to Capacitor plugins you write yourself: never assume the native side will reject gracefully; assume it can throw, and catch at the boundary.

Capacitor Stack Traces and Source Maps

When you do capture a JavaScript error in a Capacitor app, the stack trace you get is almost never useful on its own. Production bundles are minified and concatenated, so a trace like at e (bundle.js:1:48291) tells you nothing about which source line actually threw. The fix is source maps — a mapping from the minified bundle back to the original TypeScript or JavaScript.

Capacitor's build tooling emits source maps when you enable them, and most bundlers support them with a single flag.

{
  "sourcemap": true,
  "minify": true
}

The discipline that actually matters is uploading the source map for every release — and versioning it so a crash report can be matched to the exact bundle the user was running. A source map from release 1.4 is useless for a crash in release 1.5. Keep a release-to-source-map registry, and upload maps at the same moment you ship. This is the same symbolication problem native teams solve with dSYM and ProGuard mapping files, which we cover in our stack trace symbolication guide.

Minified stacks are only half the problem. Capacitor runs your JavaScript inside a WebView whose engine differs by platform — JavaScriptCore on iOS, V8 on Android. Stack trace formatting, column numbers, and even the availability of certain error properties differ between the two. A robust hybrid crash pipeline normalizes both platforms into a single canonical stack format before deduplication; otherwise the same bug shows up as two unrelated crashes in your dashboard.

Cordova's window.onerror and unhandledrejection Gaps

Cordova apps have long relied on two global handlers to catch JavaScript failures: window.onerror and the unhandledrejection event. They are essential, but they have gaps that bite teams in production.

window.onerror catches uncaught exceptions, but not every error. Errors thrown inside a cross-origin script, inside certain WebView sub-frames, or after the handler itself is clobbered can slip past. It also provides limited context — a message, a source URL, a line, and a column — but not the structured arguments or breadcrumbs you need to reproduce the failure.

window.addEventListener('error', function (event) {
  captureJsError(event.message, event.filename, event.lineno, event.colno, event.error);
});
 
window.addEventListener('unhandledrejection', function (event) {
  captureJsError(event.reason && event.reason.message, '', 0, 0, event.reason);
});

The unhandledrejection gap is subtler and more dangerous. Many frameworks — and many developers — treat a rejected promise as non-fatal, so the default is to log and continue. But in a hybrid app, a rejected promise during a plugin call, a data fetch, or a navigation transition often leaves the UI in a half-updated state that crashes moments later in a way that looks completely unrelated. Instrumenting unhandledrejection from app startup — before any framework boots — closes the most common silent-failure hole in Cordova and Capacitor apps.

WebView-Specific Crashes That Only Hybrid Apps See

Some hybrid crashes are neither JavaScript nor plugin faults — they are WebView faults, and they are the hardest to diagnose because they appear as native crashes or, worse, as nothing at all. On iOS, WKWebView can terminate its content process when memory pressure spikes, leaving your app with a blank view and no error. On Android, the system WebView updates independently of your app, so a Chrome or WebView update can change rendering or JavaScript behavior between releases of your app with zero changes on your side.

These platform failures matter because they are invisible to both the JavaScript layer and your app's native crash reporter. WebView content-process crashes on iOS are reported by the system, but correlating that termination back to a specific JavaScript action requires a breadcrumb trail — a log of the last screen, the last plugin call, and the last network request before the WebView died. A breadcrumb trail turns a blank-screen report from a mystery into a reproducible bug.

For a deeper look at WebView crash detection as a general mobile concern, see our guide on mobile app WebView crash debugging. The difference here is the framework layer: in Capacitor and Cordova, the WebView is not a component you embed — it is the entire application shell, so its failures are your failures by definition.

Wiring Bugspulse Into a Hybrid App

Because hybrid crashes span two runtimes, your error tracking has to span them too. The pattern is three layers: a JavaScript layer that captures WebView errors and plugin rejections, a native layer that captures OS-level crashes, and a correlation layer that ties a user's JavaScript error to the native crash that followed it.

On the JavaScript side, install the Bugspulse web SDK into your Capacitor or Cordova www bundle and configure it before your framework boots. Initialize the global error and unhandled-rejection handlers, then attach a release tag and a source-map reference to every report. On the native side, the Bugspulse mobile SDK captures the native crashes — the plugin faults, the WKWebView terminations, the Android ANRs — and shares the same session identifier, so one user's journey is visible end to end.

npm install @bugspulse/web
npx bugspulse init --platform capacitor --release 1.6.0

Bugspulse is built privacy-first: it captures the crash data you need to fix hybrid apps without harvesting personal information, which matters in the WebView world where JavaScript runs alongside arbitrary third-party scripts. The workflow that follows is the same discipline that makes any crash pipeline effective — release gating on crash-free rate, real-time alerting on new error fingerprints, and a weekly triage pass where hybrid crashes are deduplicated across the JavaScript and native layers into a single canonical bug. If your hybrid crash tooling already leans on platform channels in Flutter, the same boundary discipline applies — see our Flutter platform channel error handling guide.

Make Hybrid Crashes Visible

Capacitor and Cordova let small teams ship native-feeling apps fast, but that speed hides a cost: the crash surface spans two runtimes, and most tooling only watches one. Fix it by instrumenting both layers, treating the plugin bridge as a first-class failure boundary, uploading versioned source maps for every release, and closing the window.onerror and unhandledrejection gaps at app startup. When you can see a JavaScript rejection and the native crash that followed it in one timeline, hybrid crash debugging stops being a guessing game.

Start capturing that full picture today by signing up at app.bugspulse.com/register.