<CarlosDev/>

Current

Remote Compose: server-driven UI on Android

Google's first-party server-driven UI for Android — a serialized document built on the JVM and rendered by an on-device player, and how it differs from JSON SDUI.

Published
Updated
Reading
11 min
Write-up
Growing
Remote Compose banner — a serialized Compose document rendered by an on-device player.
In this record11 sections

Something quietly significant landed in Jetpack at the end of 2025. androidx.compose.remote — Remote Compose — is Google’s first-party answer to server-driven UI on Android. It reached alpha07 on March 25, 2026, and it’s worth understanding what it is and, more importantly, what problem it actually solves.

The problem that never got solved

Server-Driven UI (SDUI) has been a known pattern in Android for years. Airbnb, Twitter, Lyft, and others have built their own SDUI stacks. The common approach: define a JSON schema for components, ship a renderer that maps JSON keys to actual UI, and update the UI by changing the server payload.

It works. But it has real downsides:

  • JSON is verbose and untyped — you build your own contract and hope both sides agree
  • Renderers diverge — the client renderer and the server definition drift over time
  • No compile-time safety — a typo in a component name silently renders nothing

Meanwhile, Android’s built-in solution was RemoteViews — a serializable, cross-process UI format that’s been around since Android 1.5. Glance widgets, Wear OS tiles, lock screen widgets — they all use RemoteViews today. It’s reliable but deeply limited: no custom layouts, no arbitrary composables, no Compose at all.

Remote Compose is the successor to both.


What Remote Compose actually is

Remote Compose is a serialization layer on top of Jetpack Compose semantics. Instead of rendering to a Canvas, you write Compose-like code using the remote-creation API, and it produces a compact binary document — a serialized UI tree. That document is then transmitted (over the network, IPC, or however you want) to an Android client that renders it using the remote-player runtime.

The key insight: the client doesn’t need a Compose compiler or a Kotlin runtime for the UI code. The player just deserializes the document and paints it. No APK update required.

Server / JVM Process
  └── remote-creation API → writes Compose-like code
        ↓ produces
      [ binary document ]
        ↓ transmitted
Android Client
  └── remote-player → deserializes + renders to View/Compose host

How it differs from JSON-based SDUI

JSON SDUIRemote Compose
FormatText (verbose)Binary (compact)
Type safetyNone (runtime)Enforced at creation time
RendererCustom, divergesSystem-provided, versioned
Layout powerLimited by your schemaFull Compose layout model
First-partyNoYes (Google/Jetpack)

The Glance connection

If you’ve used Glance (the Jetpack library for widgets and Wear OS tiles), you’re using RemoteViews under the hood — the same API from 2009. Remote Compose is Glance’s future rendering backend. The migration is already in progress.

This context matters: Remote Compose isn’t an experimental curiosity. It’s the infrastructure Google is building for the next decade of widgets and cross-process UIs.

The “Back to the Future” framing that Arman Chatikyan used is apt: same problem as RemoteViews in 2009, modern solution.


Module structure

The library is split into creation (server/JVM side) and player (Android client side):

// Server / JVM — produces documents
implementation("androidx.compose.remote:remote-creation:1.0.0-alpha07")
implementation("androidx.compose.remote:remote-creation-compose:1.0.0-alpha07") // if writing from Compose

// Android client — renders documents
implementation("androidx.compose.remote:remote-player-core:1.0.0-alpha07")
implementation("androidx.compose.remote:remote-player-view:1.0.0-alpha07")

// Tooling
debugImplementation("androidx.compose.remote:remote-tooling-preview:1.0.0-alpha07")

The separation is intentional: the creation side can run on a JVM backend (a Spring service, a Cloud Function, etc.) with no Android dependency. The player side is Android-only.


The RemoteApplier boundary

One design decision that stands out: since alpha04, the RemoteApplier is enabled by default. This is a compile-time guard that prevents regular Jetpack Compose composables from being accidentally used inside Remote Compose code.

In practice, Remote Compose functions look similar to regular Compose functions, but they’re in a different composition scope. The compiler enforces the boundary. If you try to call a regular @Composable from inside a Remote Compose function, you get a compile-time error.

This is the right call — it prevents a class of bugs where non-serializable UI leaks into a document that the player can’t render.


What’s available in alpha07 (March 25, 2026)

The latest release added:

  • Non-linear font scaling — respects accessibility settings for large text
  • LayoutDirection — RTL/LTR support for international layouts
  • RemoteSpacer, RemoteImageVector, painterRemoteVector — newly public APIs
  • Semantic modifier functions — accessibility support
  • RemoteArrangement.spacedBy() — gap-based layout spacing
  • Extended RemoteDrawScope, RemoteCanvas, RemotePainter, RemoteBrush
  • RemoteFloat arithmetic operations and asRemoteDp() conversion

Breaking changes in alpha07:

  • RemoteArrangement.CenterHorizontally removed — use RemoteArrangement.Center
  • RemoteBox alignment parameter changed to a single RemoteAlignment

Current state: still alpha, but moving fast

The library has shipped 7 alpha releases in roughly 3.5 months (December 2025 → March 2026). The pace is aggressive: each release both adds new public APIs and breaks existing ones.

Milestone by milestone, the API surface has grown from almost nothing to a reasonably complete set of layout primitives. But the fact that every release still has breaking changes means this isn’t production-ready — yet.

Meaningful milestones so far:

  • alpha04: minSdk dropped from 26 to 23 — a signal the team intends broad adoption
  • alpha04: FlowLayout support added
  • alpha06: Java 11 target (may require desugaring)
  • alpha07: Font scaling + RTL — table stakes for internationalized apps

Should you use it now?

For production apps: not yet. The API breaks every two weeks, and the player runtime’s capabilities are still being defined.

For Glance widget developers: start paying attention. This is where Glance is going, and early familiarity will pay off when the migration path is formalized.

For SDUI teams: worth a serious prototype. If your team has been maintaining a custom SDUI stack, the appeal of a first-party, type-safe, binary-format alternative is obvious.

For widget/tile use cases: this is arguably the most immediate practical application, since Glance is already on this path.


From here

In the setup walkthrough that follows, we build a practical example: creating a simple Remote Compose document on the creation side, serializing it, and rendering it inside an Android app using remote-player-view.


Resources

6 min

Getting started with Remote Compose: a practical example

Build a Remote Compose document on the creation side, serialize it, and render it in an Android app with remote-player-view.

Read on its own: Getting started with Remote Compose: a practical example

Now that we’ve covered what Remote Compose is and why it exists, let’s build something real — a simple UI document, serialize it, and render it in an Android app.

Alpha warning: androidx.compose.remote is currently 1.0.0-alpha07. The API surface is still changing. Code in this post reflects the alpha07 state.


Project setup

Dependencies

Add the Remote Compose modules to your project. For a typical Android app that both creates and plays documents locally (useful for testing or widget scenarios), you need both sides:

// build.gradle.kts (app module)
dependencies {
    // Creation side — generates the document
    implementation("androidx.compose.remote:remote-creation:1.0.0-alpha07")
    implementation("androidx.compose.remote:remote-creation-compose:1.0.0-alpha07")

    // Player side — renders the document
    implementation("androidx.compose.remote:remote-player-core:1.0.0-alpha07")
    implementation("androidx.compose.remote:remote-player-view:1.0.0-alpha07")

    // Preview support during development
    debugImplementation("androidx.compose.remote:remote-tooling-preview:1.0.0-alpha07")
}

minSdk: Remote Compose requires minSdk 23 (as of alpha04). If your project targets lower, enable core library desugaring — the library uses Java 11 APIs since alpha06.

// build.gradle.kts (app module)
android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
    }
}
dependencies {
    coreLibraryDesugaring("com.android.tools.desugar_jdk_libs:2.1.4")
}

The creation side: building a document

The creation API mirrors Compose composables but operates in a different composition scope — the RemoteApplier. Functions here don’t draw to a Canvas; they build a serializable UI tree.

Think of it as writing a template that will be frozen and shipped to a device.

import androidx.compose.remote.creation.RemoteCompose
import androidx.compose.remote.creation.compose.RemoteColumn
import androidx.compose.remote.creation.compose.RemoteText
import androidx.compose.remote.creation.compose.RemoteButton
import androidx.compose.remote.creation.compose.RemoteSpacer
import androidx.compose.remote.creation.compose.remoteModifier
import androidx.compose.remote.creation.compose.fillMaxWidth
import androidx.compose.remote.creation.compose.padding

fun buildWelcomeDocument(): ByteArray {
    return RemoteCompose.capture {
        RemoteColumn(
            modifier = remoteModifier()
                .fillMaxWidth()
                .padding(24f)
        ) {
            RemoteText(
                text = "Hello from the server!",
                style = RemoteTextStyle.titleLarge()
            )

            RemoteSpacer(height = 12f)

            RemoteText(
                text = "This UI was defined outside the app and rendered locally by the player runtime.",
                style = RemoteTextStyle.bodyMedium()
            )

            RemoteSpacer(height = 24f)

            RemoteButton(
                onClick = RemoteAction.log("button_clicked"),
                modifier = remoteModifier().fillMaxWidth()
            ) {
                RemoteText(text = "Tap me")
            }
        }
    }
}

RemoteCompose.capture { ... } runs the creation block and returns a ByteArray — the serialized document. You can write this to disk, send it over HTTP, or pass it through IPC.

Key constraint: You cannot call regular @Composable functions inside a RemoteCompose.capture { } block. The RemoteApplier enforces this at compile time.


Simulating a server response

In a real production app, the document would come from a backend service. For this example, let’s simulate it locally with a coroutine:

// In a ViewModel or repository
class DocumentRepository {
    suspend fun fetchWelcomeDocument(): ByteArray {
        // Simulate network delay
        delay(300)
        // In production: val response = httpClient.get("/ui/welcome")
        //                return response.body()
        return buildWelcomeDocument()
    }
}

The document is just bytes. You can cache it, store it in a database, or version it however your backend works.


The player side: rendering in a fragment

remote-player-view provides RemoteComposePlayerView — a View (not a Composable) that accepts a document and renders it. You can embed it in any XML layout or in a Compose UI via AndroidView.

Option A: using it in XML layout

<!-- res/layout/fragment_remote.xml -->
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.compose.remote.player.view.RemoteComposePlayerView
        android:id="@+id/remote_player"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <ProgressBar
        android:id="@+id/loading"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:visibility="gone" />

</FrameLayout>
class RemoteFragment : Fragment(R.layout.fragment_remote) {

    private val viewModel: RemoteViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val player = view.findViewById<RemoteComposePlayerView>(R.id.remote_player)
        val loading = view.findViewById<ProgressBar>(R.id.loading)

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.documentState.collect { state ->
                    when (state) {
                        is DocumentState.Loading -> {
                            loading.isVisible = true
                            player.isVisible = false
                        }
                        is DocumentState.Success -> {
                            loading.isVisible = false
                            player.isVisible = true
                            player.setDocument(state.bytes)
                        }
                        is DocumentState.Error -> {
                            loading.isVisible = false
                            // Show error UI
                        }
                    }
                }
            }
        }
    }
}

Option B: embedding in Compose via AndroidView

If your app is fully Compose-based:

@Composable
fun RemoteScreen(documentBytes: ByteArray?) {
    if (documentBytes == null) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            CircularProgressIndicator()
        }
        return
    }

    AndroidView(
        factory = { context ->
            RemoteComposePlayerView(context).apply {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT
                )
            }
        },
        update = { playerView ->
            playerView.setDocument(documentBytes)
        },
        modifier = Modifier.fillMaxWidth()
    )
}

Handling remote actions

Buttons and interactive elements in the document can emit RemoteActions — named events that the host app handles. The document declares what to emit; the player surfaces them:

// When building the document:
RemoteButton(
    onClick = RemoteAction.named("navigate_to_details", mapOf("id" to "42"))
) { ... }

// In the host app, set an action listener on the player view:
player.setActionListener { action ->
    when (action.name) {
        "navigate_to_details" -> {
            val id = action.params["id"]
            findNavController().navigate(
                RemoteFragmentDirections.toDetails(id)
            )
        }
        "button_clicked" -> {
            analytics.track("remote_button_tapped")
        }
    }
}

This keeps business logic in the app where it belongs, while the document controls layout and content.


The ViewModel

sealed interface DocumentState {
    data object Loading : DocumentState
    data class Success(val bytes: ByteArray) : DocumentState
    data class Error(val message: String) : DocumentState
}

class RemoteViewModel(
    private val repository: DocumentRepository
) : ViewModel() {

    private val _documentState = MutableStateFlow<DocumentState>(DocumentState.Loading)
    val documentState: StateFlow<DocumentState> = _documentState.asStateFlow()

    init {
        loadDocument()
    }

    private fun loadDocument() {
        viewModelScope.launch {
            _documentState.value = DocumentState.Loading
            runCatching { repository.fetchWelcomeDocument() }
                .onSuccess { _documentState.value = DocumentState.Success(it) }
                .onFailure { _documentState.value = DocumentState.Error(it.message ?: "Unknown error") }
        }
    }

    fun retry() = loadDocument()
}

What this enables

With this setup, the UI that renders in RemoteComposePlayerView can be updated by changing what the server returns — without any app update. The user sees a different layout, different text, different button, on their next launch.

Practical use cases:

  • A/B testing UI variants without deploying new builds
  • Feature flags that show or hide UI sections
  • Widgets and tiles (this is how Glance will work once migrated)
  • Dynamic onboarding flows that the product team can iterate on independently

What it can’t do yet

Being alpha07, there are real limitations:

  • No arbitrary composables — you can only use what remote-creation exposes. No custom composables from your codebase.
  • No ViewModel scope — the document is stateless. Any stateful logic lives in the host app and is communicated via RemoteAction.
  • No navigation — the player renders a single document; navigation between screens is the host app’s responsibility.
  • API breaks frequently — every alpha release has changed something. Don’t build production infrastructure on this until beta.

What to watch for

The trajectory is clear. As the public API surface stabilizes and Glance migration progresses, Remote Compose will become the standard way to build widgets, lock screen components, Wear OS tiles, and cross-process UIs. The pattern you learn here carries forward.

Keep an eye on:

  • remote-creation-jvm — the server-side JVM module that will unlock true backend document generation
  • Glance’s migration roadmap to remote-player
  • The alpha → beta transition, which will signal API stability

Resources

Filed under

Citations

2

External references