---
title: "Decoupling WebView State from Activity Lifecycles with WebContent"
description: "How the new androidx.web library separates the underlying Chromium session from the view hierarchy to preserve DOM, JavaScript heap, and scroll state across configuration changes."
pubDatetime: 2026-11-07
modDatetime: 2026-11-07
author: "Arda Kazancı"
canonicalUrl: "https://ardakazanci.com/posts/decoupling-webview-state-from-activity-lifecycles-with-webcontent/"
tags: ["android", "kotlin", "compose", "architecture", "androidx"]
---
Android developers handling in-app web browsing eventually run into the same architectural limitation: `android.webkit.WebView` combines an Android `View` and an active Chromium browser engine into a single class.

Because it inherits directly from `View`, retaining an instance across configuration changes in a `ViewModel` holds an indirect reference to an `Activity` context, causing a memory leak. Conversely, destroying and recreating the view on rotation resets the page. Unlike serializable UI fields persisted in a [`SavedStateHandle`](/posts/savedstatehandle-savedstatecontainer/), `WebView.saveState` only serializes the navigation back-forward history; it cannot save the in-memory JavaScript heap, ongoing network requests, form inputs, or active DOM modifications.

On November 4, 2026, Google released `androidx.web:web:1.0.0-alpha01`, introducing the `WebContent` API to decouple the engine session from the UI tree.

## Core components

The library splits the traditional responsibilities of `WebView` into three primary classes:

1. **`WebContent`**: An engine controller that implements `AutoCloseable`. It maintains the page URL, JavaScript state, DOM tree, `WebViewClient`, and `WebChromeClient`. It does not draw pixels and has no view hierarchy constraints.
2. **`WebContentView`**: A direct subclass of `android.webkit.WebView`. It acts as the actual on-screen view, bound to an active `Activity` context.
3. **`DetachedWebContentView`**: A specialized, non-renderable `WebContentView` created internally when `detach()` is called. When detached, Chromium routes engine callbacks (such as `onPageFinished`) to this fallback view so they never deliver a `null` view or leak the dead `Activity`. If this view is ever added to a view hierarchy, its `onAttachedToWindow` callback throws an `IllegalStateException`.

## Safety checks and runtime requirements

The Chromium support layer enforces strict preconditions:

* **Factory enforcement:** The `attach` method accepts an `Activity` context and a factory function: `content.attach(context, ::WebContentView)`. Under the hood, Chromium wraps the passed context with an internal safety wrapper before handing it to the factory. If a caller tries to bypass the factory or returns a view constructed with an external context, Chromium throws an exception.
* **Detached from parent before detach:** Calling `webContent.detach()` requires the active `WebContentView` to already be removed from its parent view group (`view.parent == null`). Attempting to detach an attached view throws an `IllegalStateException`.
* **Feature verification:** The feature requires support in the device's installed WebView APK. You must check `WebFeature.isFeatureSupported(WebFeature.WEB_CONTENT)` before calling any `WebContent` builder method.

## Implementation with Jetpack Compose

Add the dependency to your version catalog:

```toml title="gradle/libs.versions.toml"
[versions]
web = "1.0.0-alpha01"

[libraries]
androidx-web = { group = "androidx.web", name = "web", version.ref = "web" }
```

### 1. The ViewModel

The `ViewModel` owns the `WebContent` session and closes it in `onCleared()`, without importing `Context` or any Android `View` class.

```kotlin title="WebContentViewModel.kt"
package com.example.androidxnews.webcontent

import androidx.lifecycle.ViewModel
import androidx.web.WebContent
import androidx.web.WebFeature

class WebContentViewModel : ViewModel() {

    val webContent: WebContent? =
        if (WebFeature.isFeatureSupported(WebFeature.WEB_CONTENT)) {
            WebContent()
        } else {
            null
        }

    private var initialLoaded = false

    fun markInitialLoadNeeded(): Boolean {
        if (initialLoaded) return false
        initialLoaded = true
        return true
    }

    override fun onCleared() {
        webContent?.close()
    }
}
```

### 2. The Composable

`AndroidView` owns the `Context` and `WebContentView` lifecycle in the UI layer. When the device WebView APK lacks `WEB_CONTENT` support, `factory` falls back to constructing `WebContentView(context)` directly. Because `AndroidViewHolder` runs `onRelease` before detaching its child from the container, `removeView` must execute before `webContent.detach()`.

```kotlin title="WebContentScreen.kt"
package com.example.androidxnews.webcontent

import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.web.WebContentView

@Composable
fun WebContentScreen(
    modifier: Modifier = Modifier,
    viewModel: WebContentViewModel = viewModel(),
) {
    val webContent = viewModel.webContent
    AndroidView(
        modifier = modifier.fillMaxSize(),
        factory = { context ->
            if (webContent != null) {
                webContent.attach(context, ::WebContentView).also { view ->
                    if (viewModel.markInitialLoadNeeded()) {
                        configure(view)
                    }
                }
            } else {
                WebContentView(context).also(::configure)
            }
        },
        onRelease = { view ->
            (view.parent as? ViewGroup)?.removeView(view)
            webContent?.detach()
        },
    )
}

@SuppressLint("SetJavaScriptEnabled")
private fun configure(view: WebContentView) {
    view.settings.javaScriptEnabled = true
    view.webViewClient = WebViewClient()
    view.loadUrl("https://developer.android.com")
}
```

## State preservation details

When a new `WebContentView` attaches to an existing `WebContent` session:

* **JavaScript and DOM:** Evaluated scripts, modified document elements, and user inputs remain untouched in Chromium's render process.
* **Scroll offset:** Scroll positions live in Android's `View` system rather than Chromium's renderer. When switching views, `WebContentView.transferViewState` reads `scrollX` and `scrollY` from the departing view and applies them via `scrollTo` to the new view.
* **Resource cleanup:** `WebContent` keeps memory allocated in the browser engine process until `close()` is explicitly invoked. Scoping its lifecycle to a `ViewModel` ensures `close()` runs when the user permanently leaves the flow.
