Android WebView Deep Dive: Chromium Architecture and JS Bridge Security

Last year I spent two days debugging a blank WebView screen. The final root cause was that the renderer process had been killed by the system, and the Java-side Bridge callback disappeared silently. That incident changed how I think about WebView. It is not just “a browser embedded in a View.” Its multi-process architecture and JS Bridge communication path have more traps than they first appear to.

Chromium’s multi-process architecture

On Android, WebView is essentially a trimmed-down Chromium. Since Android 7.0, WebView is no longer baked only into the system image. It can be updated independently through Google Play as an APK, which fixes the old problem where security patches lagged behind OS releases.

Chromium’s core design is process isolation. WebView involves at least three processes:

  • Browser process: the UI thread, responsible for network requests, Cookie management, and permission prompts
  • Renderer process: runs JavaScript, parses HTML and CSS, and executes the rendering pipeline
  • GPU process (optional): handles hardware-accelerated compositing and rasterization

On Android, the Browser process runs inside the app’s main process, while the Renderer is a separate sandboxed process. This directly shapes the JS Bridge implementation. Java objects live in the Browser process. The JS engine lives in the Renderer process. Communication between them goes through Mojo IPC.

That IPC layer introduces a key failure mode: the Renderer process can be reclaimed by the system at any time. I have seen this in production. A user switched the app to the background and came back. The WebView looked normal, but every method call on window.Android failed because the old Bridge reference pointed to a destroyed Renderer. It did not recover automatically.

Rendering pipeline: from DOM to screen pixels

WebView rendering is not a simple “draw the whole frame” operation. Chromium’s rendering pipeline has four stages:

Style -> Layout -> Paint -> Composite

Two concepts matter a lot for client-side developers.

Tiling: the Paint stage does not directly generate a bitmap. It generates SkPicture commands. Rasterization is then performed by tiles, often 256 x 256 pixels each. Tiles outside the viewport are not rasterized immediately. This is the root of the white blocks you sometimes see while scrolling a WebView.

Compositor layers: not every element is drawn on the same layer. CSS animations such as transform and opacity can be promoted to independent compositor layers and processed directly by the GPU without triggering main-thread repaint. Too many compositor layers, however, add GPU memory overhead.

When investigating rendering performance, I usually start with two tools:

# Enable rendering debug and show compositor layer borders.
adb shell "echo 'show-composited-layer-borders' > /proc/sys/kernel/chromium-command-line"

# Inspect GPU rendering cost.
adb shell dumpsys gfxinfo <package_name> framestats

One trap I have hit: on low-end devices, when the WebView content area reaches 2000 x 4000 pixels, rasterizing all tiles can require 40 to 60 MB of texture memory. If hardware acceleration is also forced through setLayerType(LAYER_TYPE_HARDWARE, null), OOM becomes easy to trigger. The fix is to constrain WebView height or use virtual scrolling.

Three ways to implement a JS Bridge

The core JS Bridge question is simple: how can Java objects be called safely from JS, and how should results be returned?

Option 1: addJavascriptInterface, official but constrained

This is the most direct approach. Before Android 4.2, it had a serious security vulnerability because reflection could be used to call Runtime.exec().

class JsBridge {
    @JavascriptInterface
    fun getToken(): String = tokenManager.fetchToken()

    @JavascriptInterface
    fun navigate(url: String) {
        handler.post { webView.loadUrl(url) } // Must run on the main thread.
    }
}

webView.addJavascriptInterface(JsBridge(), "NativeBridge")

The JS side calls it directly:

const token = await window.NativeBridge.getToken();
window.NativeBridge.navigate('https://next.page');

The @JavascriptInterface annotation is the security mechanism introduced in Android 4.2. Only annotated methods are exposed to JavaScript. But this approach has a trap that catches many teams: synchronous callbacks.

Every @JavascriptInterface method call is synchronous and blocking. The JS thread must wait for the Java method to return before it can continue. If getToken() performs a network request, the whole Renderer thread blocks and the page stops responding. Use an async callback instead:

@JavascriptInterface
fun getToken(callbackId: String) {
    tokenManager.fetchTokenAsync { token ->
        webView.evaluateJavascript(
            "window.__bridgeCallback('$callbackId', '$token')", null
        )
    }
}

Option 2: URL interception, compatible but slow

Intercept a custom scheme in shouldOverrideUrlLoading:

webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(
        view: WebView?, request: WebResourceRequest?
    ): Boolean {
        val url = request?.url?.toString() ?: return false
        if (url.startsWith("bridge://")) {
            handleBridgeCommand(url)
            return true
        }
        return false
    }
}

Each call triggers a loadUrl, then goes through full URL parsing and an IPC round trip. Latency is typically between 50 and 200 ms. The benefit is compatibility. X5-based WebViews and older systems can usually handle it. I used this approach for a WeChat SDK integration because X5 support for evaluateJavascript was inconsistent.

The idea is to use evaluateJavascript to send callbacks to JS, while the JS side maintains a call queue.

JS-side wrapper:

const bridge = {
  callbacks: {},
  call(method, params) {
    return new Promise(resolve => {
      const id = Date.now() + Math.random();
      this.callbacks[id] = resolve;
      window.NativeBridge.invoke(method, JSON.stringify(params), id);
    });
  }
};
// NativeBridge.invoke maps to an @JavascriptInterface method.

Java-side async response:

@JavascriptInterface
fun invoke(method: String, params: String, callbackId: String) {
    when (method) {
        "getToken" -> {
            tokenManager.fetchTokenAsync { token ->
                webView.post {
                    evaluateJavascript(
                        "window.bridge.callbacks['$callbackId']('$token');" +
                        "delete window.bridge.callbacks['$callbackId'];",
                        null
                    )
                }
            }
        }
    }
}

Notice the webView.post {} call. It guarantees execution on the main thread. evaluateJavascript must be called on the main thread, or it can crash directly.

Three essentials for secure communication

Domain allowlist: a JS Bridge should not be active for every URL. Validate webView.url in shouldInterceptRequest or inside the Bridge invoke method, and allow only trusted domains. An XSS attacker can inject scripts that call Bridge APIs. Without domain validation, the Bridge is effectively exposed.

Sensitive data redaction: when passing tokens or user information through the Bridge, avoid injecting complete data directly into the string passed to evaluateJavascript. The JS side can read all scripts on the current page. If the page is compromised, data embedded in concatenated JS strings can be stolen.

// Bad: the token appears directly in the JS string.
evaluateJavascript("setToken('${userToken}')", null)

// Better: send it through an encrypted channel.
bridgeChannel.sendEncrypted(userToken, sessionKey)

Renderer crash recovery: WebView’s onRenderProcessGone on Android O and later is the final recovery hook. After the Renderer process crashes, all internal WebView state is lost and the WebView must be rebuilt:

override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
    if (detail?.didCrash() == true) {
        // Renderer crashed. Rebuild the WebView.
        view?.destroy()
        recreateWebView()
    }
    return true // true means handled. Otherwise the app will crash.
}

Returning false here destroys the Activity directly. I have hit this exact trap. The documentation does not make it obvious enough.


Android WebView development is not exactly pleasant. Documentation is scattered, ROM behavior differs, and the debugging toolchain is fragmented. But once the multi-process architecture and IPC mechanism are clear, most strange issues become explainable.

In day-to-day work, I usually do three things: wrap a unified Bridge layer so business code does not depend on low-level addJavascriptInterface and evaluateJavascript details; enforce domain validation and authentication at the Bridge entry point; and monitor onRenderProcessGone frequency, because that metric directly reflects production WebView quality.

If you maintain a product that heavily depends on WebView, spending a day refactoring the Bridge into an async message-queue model usually pays for itself many times over.