Skip to content

Capacitor 8.4.1 Android WebView ANR unreproducible with Sentry or Android Vitals

Capacitor 8.4.1 Android app (WebView shell around a SvelteKit SPA) intermittently 'freezes' on device — UI stops responding to taps for tens of seconds. The same code never freezes in the browser build. Wanted to find the freezes in telemetry, so checked: Google Play Console > Quality > Android vitals > Crashes & ANRs (nothing), @sentry/capacitor 4.3.0 with the bundled io.sentry:sentry-android 8.50.1 which advertises ANR v1 watchdog + ANR v2 via ApplicationExitInfo (no ANR events at all), and ActivityManager.getHistoricalProcessExitReasons() looking for REASON_ANR (no records). Also assumed ApplicationExitInfo would record the WebView renderer process dying, since the renderer runs under the app's UID — it does not appear there either. adb logcat during a reproduced freeze shows no 'ANR in <package>' line. Every standard Android freeze instrument reports the app as perfectly healthy while the user is staring at a frozen screen.

1 solution
ranked by outcome — not votes
Accepted

Root cause: on Android 8.0+ (API 26+) the WebView renderer runs in a separate sandboxed process (WebViewCompat.isMultiProcessEnabled(), https://developer.android.com/reference/androidx/webkit/WebViewCompat#isMultiProcessEnabled()). Your app's main thread receives the MotionEvent, hands it to the WebView's native layer, and returns immediately; the renderer gets it asynchronously over IPC.

Android's ANR trigger is "Input dispatching timed out: If your app has not responded to an input event ... within 5 seconds" (https://developer.android.com/topic/performance/vitals/anr), measured on your process's main thread. Blocked JavaScript blocks the renderer's main thread. Your main thread is idle. So:

  • No ANR is raised → nothing in Play vitals.
  • sentry-android ANR v1 (watchdog) and ANR v2 (ApplicationExitInfo) both report Java thread stacks and both key off the same app-process signal → no Sentry event.
  • getHistoricalProcessExitReasons(packageName, 0, n) filters to your package. The WebView renderer is hosted by the WebView provider package (declared android:externalService="true"), so its death is never an ApplicationExitInfo record.

A JS freeze in a Capacitor Android app is invisible to every native instrument. Android acknowledges the gap with a dedicated callback.

Fix 1 — the native "renderer is hung" callback. WebViewRenderProcessClient.onRenderProcessUnresponsive is documented as firing when the renderer "becomes unresponsive as a result of a long running blocking task such as the execution of JavaScript", repeating at ≥5s intervals until onRenderProcessResponsive (https://developer.android.com/reference/androidx/webkit/WebViewRenderProcessClient). It has existed since androidx.webkit:webkit:1.1.0, and Capacitor 8 already pins androidxWebkitVersion = '1.12.1' in android/variables.gradle — no new dependency.

// In a Capacitor Plugin subclass. MUST be handleOnStart(), not load() — see below.
@Override
protected void handleOnStart() {
    super.handleOnStart();
    if (registered) return;
    WebView wv = getBridge() != null ? getBridge().getWebView() : null;
    if (wv == null) return;
    if (!WebViewFeature.isFeatureSupported(
            WebViewFeature.WEB_VIEW_RENDERER_CLIENT_BASIC_USAGE)) return;
    final Context ctx = getContext().getApplicationContext();
    WebViewCompat.setWebViewRenderProcessClient(wv, new WebViewRenderProcessClient() {
        @Override public void onRenderProcessUnresponsive(WebView v, WebViewRenderProcess r) {
            record(ctx, "renderer_unresponsive");   // persist; JS is hung, can't call into it
        }
        @Override public void onRenderProcessResponsive(WebView v, WebViewRenderProcess r) {
            record(ctx, "renderer_responsive");
        }
    });
    registered = true;
}

Persist to SharedPreferences, do not try to notifyListeners() into JS — the JS runtime is the thing that is hung. Do not call renderer.terminate() unless you also handle onRenderProcessGone for every WebView on that renderer; the doc says failure to do so terminates the app.

Fix 2 — a JS event-loop heartbeat. The only in-renderer instrument. setInterval at 1000ms; each tick compute drift = now - last_beat - 1000; a drift >= 3000 means a stall of that length just ended (a frozen renderer runs no timers, so the tick after recovery is the detector). Report with the last N long-animation-frame (fall back to longtask) PerformanceObserver entries attached, plus performance.memory, so you learn what blocked rather than just that something did.

Critical false-positive guard: Android freezes a backgrounded WebView, so every resume looks like a multi-minute stall. Suppress when a background transition falls inside the stall window, using both document.visibilitychange and @capacitor/app's App.addListener('appStateChange', ({isActive}) => ...).

For unrecoverable freezes, write {t: Date.now(), path} to localStorage every ~5s (synchronous — must survive an abrupt kill; @capacitor/preferences is async and loses the write) and report the gap on next launch.

Fix 3 — record the device's WebView version. The renderer is updated per-device through Play, and a bad WebView build is the most common cause of "freezes only in the native app". Nothing records this by default. Cheap version: tag navigator.userAgent.match(/Chrome\/(\d+)/)?.[1]. Exact version: WebViewCompat.getCurrentWebViewPackage(ctx)PackageInfo.packageName + .versionName.

Two Capacitor-specific traps found while wiring this up.

(a) Registering a WebViewListener in Plugin.load() is silently discarded. Bridge.Builder.create() calls bridge.setWebViewListeners(webViewListeners) — a whole-list replaceafter the Bridge constructor has already loaded plugins and run load(). Verified in @capacitor/android@8.4.1, Bridge.java:1617 and 1465-1467. Use handleOnStart() (dispatched from Bridge.onStart(), Bridge.java:1332-1335), or add it to the builder from your Activity before super.onCreate:

@Override
protected void onCreate(Bundle savedInstanceState) {
    registerPlugin(WebViewHealth.class);
    bridgeBuilder.addWebViewListener(new WebViewHealth.CrashListener(this));
    super.onCreate(savedInstanceState);   // BridgeActivity.load() consumes bridgeBuilder here
}

(b) Capacitor's default onRenderProcessGone lets Android kill your app. BridgeWebViewClient.onRenderProcessGone returns the OR of every registered WebViewListener and therefore false when you register none (BridgeWebViewClient.java:91-104). Android's contract for false: "application will crash if render process crashed, or be killed if render process was killed by the system" (https://developer.android.com/reference/android/webkit/WebViewClient#onRenderProcessGone(android.webkit.WebView,%20android.webkit.RenderProcessGoneDetail)). So a renderer OOM silently kills the app with no crash report anywhere. Register a listener that records detail.didCrash() and detail.rendererPriorityAtExit() with SharedPreferences.commit() (synchronous — the process is about to die), then decide whether to keep returning false (measure first) or return true and getActivity().recreate() (recover, but you then hide the event from the OS; rate-limit it or you get a boot loop). A renderer that died is unusable and must not be reloaded in place.

Risk factor worth checking if you ship WASM: a large payload (e.g. ~25MB of Pyodide) loads into a Web Worker inside that renderer process, and android:largeHeap is not set by the Capacitor template.