Android screens compiled to bytes, cached on a CDN, filled with each user's data at runtime; and redesigned by merging a PR.

Most server-driven UI systems are a JSON schema plus a widget registry. The server sends {"type": "card", "children": [...]}, the client walks the tree and builds real components from it. That holds up until a designer wants a gradient border, or a badge that overlaps the avatar. Now you need a new node type, which means a new app version, which means waiting months for adoption.
Remote Compose (androidx.compose.remote) drops the schema. You write the screen in a Kotlin DSL on a JVM, compile it to a small binary of layout and drawing operations, and a player on the device runs it. The client never learns what a card is.
Remote Compose is two halves of the same idea: a document format and a player that runs it.
A document is an ordered stream of operations written into a flat buffer - set a paint colour, lay out a column, draw text, register a click handler, declare a variable. It is not a description of a UI tree for someone else to interpret. It is closer to a program: some operations are values, some are expressions over those values, and the player evaluates them as it paints.
Three pieces matter in practice:
There are three front doors into the format, and they all come out as the same bytes. The player never learns which one you used.
| Path | What it is | Runs where | State |
|---|---|---|---|
| Compose API | Composables that emit a document instead of drawing, from inside an app | Android, needs the Compose runtime | In the published alphas |
| Kotlin DSL | A Kotlin builder that writes the operations directly | Any JVM: a server, a build job, a test | In the published alphas |
| JSON | The same document written as data, parsed into the same operations | Anywhere that can emit JSON | Parser is in the alphas, but the path is still in development |
A Kotlin DSL is not a separate language. It is ordinary Kotlin: functions that take a lambda with a receiver, so Column { Text("Hi") } is plain method calls that happen to read like markup. The receiver decides what is legal inside the braces and the compiler checks it, which is why it feels like a language of its own without needing a plugin, a runtime, or a parser.
Why the DSL?
The Compose API needs the Compose runtime, so a JVM server can't use it. JSON is open to any backend, but its path is still in development. That leaves the Kotlin DSL. The choice costs nothing downstream: the document, the manifest and the player are the same either way, so a JSON-authored screen can drop in later.
The device side ends up being about ten lines. Fetch bytes, parse, hand them to the player, push values into named slots:
val document = CoreDocument().apply { initFromBuffer(bytes) }player.setDocument(document)player.stateUpdater.setUserLocalString("user.name", "Asha")player.invalidate()Add it to the two sides separately, since only one of them is an Android module:
// authoring — plain Kotlin/JVM, runs wherever you build screensimplementation("androidx.compose.remote:remote-core:1.0.0-alpha19")implementation("androidx.compose.remote:remote-creation-core:1.0.0-alpha19")implementation("androidx.compose.remote:remote-creation-jvm:1.0.0-alpha19")
// playing — your Android appimplementation("androidx.compose.remote:remote-core:1.0.0-alpha19")implementation("androidx.compose.remote:remote-player-core:1.0.0-alpha19")implementation("androidx.compose.remote:remote-player-view:1.0.0-alpha19")implementation("androidx.compose.remote:remote-player-compose:1.0.0-alpha19")The simplest useful screen has nothing personal on it. Write it, compile it, put the bytes somewhere the app can fetch them:
fun WelcomeScreen(): ByteArray = rcDocument { Column(Modifier.fillMaxWidth().padding(16.rdp)) { Text("Welcome back", fontSize = 22.rsp) Text("Your account is up to date", fontSize = 14.rsp) }}That's a whole screen in a couple of kbs. The app downloads it, the player draws it. Change the copy, change the layout, re-upload - every user sees the new screen on their next launch. No release, no flag, no adoption curve.
rcDocument is a thin wrapper. It writes a header (canvas size, density behaviour, an optional wrap-content flag), then runs your lambda against an RcScope and hands back the bytes. Every call inside that lambda appends operations to one flat buffer through a RemoteComposeWriter. There is no tree on the server. A Column is a start operation, whatever its content emitted, and an end operation.
Literals don't stay literals. A string becomes a text-data operation with an id, and the layout operation that draws it stores the id, not the characters. A dp value becomes a float expression with an id. A named slot is a name bound to an id. So a finished document is mostly a table of values followed by operations that point into it, which is exactly what makes those values replaceable later. Roughly, the welcome screen above serialises to:
Header 360 × 800, density 1, DENSITY_BEHAVIOR_PIXELS FloatExpression id 1 = 16 * density ← 16.rdp, written once per document FloatExpression id 2 = 22 * density ← 22.rsp FloatExpression id 3 = 14 * density ← 14.rsp Column fillMaxWidth · padding → id 1 TextData id 4 = "Welcome back" TextLayout text → id 4 · size → id 2 TextData id 5 = "Your account is up to date" TextLayout text → id 5 · size → id 3 End
On the device, initFromBuffer reads the stream back and folds the start/end pairs into a component tree. setDocument then runs every data operation once (values are cached, names are registered), and the paint loop walks the tree, evaluating expressions as it goes. The tree exists only inside the player. The format is the list.
This is the entire promise, and it already works. It's also useless, because no screen worth shipping is static.
Real screens say "Welcome back, Asha". So how does Asha get into the bytes?
The obvious answer is to render the screen once per user, with her name already drawn in. But a document that belongs to one user breaks three things:
So Asha's name can't be in the binary. It still has to end up on the screen.
The format already has the tool for this. A document can hold named variables as well as literals: the player keeps them in a state map, draw operations point at them, and the host can set one by name at runtime. Anything prefixed USER: is a hole the host is allowed to fill.
So the server declares a hole where the value goes:
val name = remoteNamedText("USER:user.name", "")Text(name, fontSize = 22.rsp)The client fills it after the document is playing:
player.stateUpdater.setUserLocalString("user.name", "Asha")player.invalidate()The binary that ships is identical for everyone; it carries the empty default, not Asha. Her name travels separately, as a small JSON response from an ordinary product API, and lands in the hole at runtime. The layout is cacheable forever; the only per-user bytes on the wire are the values themselves.
remoteNamedText("USER:user.name", "") writes two operations: a text-data op holding the default, and a named-variable op binding the name to that op's id. Text(name) does not copy the string; it references the id. When the player loads the document it runs those data operations and builds a map from name to id.
setUserLocalString("user.name", "Asha") prepends the USER: domain, looks the name up in that map, and overrides the value behind the id. Every operation listening to that id is marked dirty and re-reads it on the next paint. No re-inflation, no layout rebuild, no new bytes: a string swapped in a table.
server remoteNamedText("USER:user.name", "") → TextData id 7 = "" + NamedVariable "USER:user.name" → 7
Text(name) → TextLayout reads id 7
device setUserLocalString("user.name", "Asha")
"USER:" + "user.name" → name map → id 7 → override → listeners dirty → repaintForget the prefix. The app adds USER: itself, so the document must declare USER:user.name while the app sets user.name. Declare it bare and the lookup misses, quietly.
Declare the slot inside a container. Inflation nests operations under the component that emitted them. A named-variable op that ends up inside a Column is dropped from the nested tree, never reaches the name map, and every push against it does nothing. Declare every slot first, at the root of the document, and pass the handles down.
Slots come in a handful of types, and each one binds wherever a literal of that type would go:
| Declare at the root | Handle | Binds into | The app fills it with |
|---|---|---|---|
remoteNamedText | RcText | Text(text = …), merged payloads, deeplinks | setUserLocalString |
remoteNamedInteger | RcInteger | Modifier.visibility(…), StateLayout | setUserLocalInt |
remoteNamedFloat | RcFloat | Any expression: heights, alphas, conditions | setUserLocalFloat |
remoteNamedColor | RcColor | background(…), text colour | setUserLocalColor |
remoteNamedBitmapUrl | RcImage | Image(image = …) | setUserLocalBitmap |
That's the whole trick. Everything below is consequences of it.
The binary is on the device with its holes empty. Something has to say which APIs fill them.
It can't be the app. Hardcode "the profile screen calls /user/details and /kyc/status" into the client and you're back to an app release every time a screen needs a new field, the exact thing this was meant to avoid.
So the screen declares its own endpoints, next to the binary that needs them, and the build publishes both into a manifest:
{ "screen": "profile", "layoutVersion": 4, "binary": [ { "url": "https://cdn.example.com/prod/profile-1/9", "sha256": "d37f6833…", "dataEndpoints": [ { "url": "https://api.example.com/user/details", "method": "GET", "key": "user" }, { "url": "https://api.example.com/kyc/status", "method": "GET", "key": "kyc" } ] } ]}That manifest URL is the only one the app ever constructs. Everything else it does is follow links: fetch the binary named by binary[0].url (or skip it, if that URL is already on disk), fire every dataEndpoints entry in parallel, drop the values into the holes as they land.
Which means the client knows one URL shape and no screen names. Adding a field is a server change. Pointing a screen at a different API is a server change. A brand new screen is a server change, and it appears on devices that shipped months ago.
One binary per user is solved. One binary per device isn't.
So scale it yourself. The player exposes the device's real density as a variable, and any value in the document can be an expression instead of a literal:
The format has a mode for this - set DENSITY_BEHAVIOR_DP and the player scales for you; but in these alphas it's half-built. It scales padding, offset, border, clip and spacing. It never scales explicit width or height, and never scales text. You get correct padding around fixed-size boxes with wrong-sized type in them.
Modifier.width(200.rdp).padding(16.rdp) // emits 200 * density, 16 * densityText(fontSize = 22.rsp)Every dp becomes value * density, resolved before layout against whatever density the device actually has. Same bytes, pixel-correct everywhere, no device metrics on the wire. Just keep the document on DENSITY_BEHAVIOR_PIXELS, or the player scales your scaled values and everything doubles.
Two artifacts, delivered independently, with nothing in common but a manifest that points at them.
| Artifact | Contains | Changes when | Delivery |
|---|---|---|---|
| Binary skeleton | Layout, styling, static text, click actions, analytics payloads, named data slots | You redesign the screen | Downloaded once per content version, cached on disk, immutable at the CDN |
| Data payload (JSON) | Per-user values for those slots | Per user, per refresh | Ordinary product APIs, fetched every launch |
Millions of users share one binary of a few kbs. A warm start costs a manifest fetch and the API calls that would have happened anyway, because the binary is already on disk.
Which raises the obvious question: if no service renders that binary per request, who renders it, and when?
A build does, once. It compiles every screen to bytes, hashes each one, uploads whatever changed, republishes the manifests and clears the CDN edge. That's an ordinary job, so it lives wherever your tests already live - Jenkins, GitHub Actions.
BUILD (CI, on every merge to main)
1 render every screen Kotlin DSL ──► .rc bytes
2 hash each section sha256
3 compare to live same hash ──► reuse the URL
new hash ──► freeze at a new one
4 republish manifest the only object that ever moves
5 purge the CDN edge
RUNTIME (on device, when the screen opens)
1 GET manifest.json the only URL the app builds
2 GET the binary URL skipped if it's already on disk
3 GET data endpoints product APIs, all in parallel
4 parse the bytes CoreDocument.initFromBuffer()
5 fill the slots setUserLocal*() then invalidate()
6 handle taps onNamedAction(back / link / analytics)A designer moves a row and rewrites a label. You edit the Kotlin, open a PR, merge it.
CI renders the screen and hashes the bytes. The hash doesn't match the live manifest, so the old URL is left alone and the new bytes are frozen at a fresh one; nothing is ever overwritten.
sha256(bytes) == live manifest's sha → reuse the URL, upload nothing otherwise → freeze at the next free versionThe manifest is repointed, the edge is purged, and that's the deploy. Whoever opens the screen next revalidates their manifest, gets a URL their device has never seen, and pulls two kbs. Everyone else pulls nothing.
Since binaries are frozen, a release is just one small mutable file; and its headers are where people get burned:
{env}/{screen}-{i}/{v} Cache-Control: public, max-age=31536000, immutable{env}/{screen}/manifest.json Cache-Control: no-cache, s-maxage=31536000If you build one of these, the host integration is where you'll spend your debugging time, because the player's lifecycle has a few sharp facts that aren't obvious from the API surface:
| Fact | Consequence |
|---|---|
| setDocument resets state, wiping every applied override | Re-apply everything in the bind pass, every time |
| setUserLocal* stores a value but invalidates nothing | Call invalidate() after a data push, or a static document never repaints |
| Bitmap decode is synchronous on the binding thread; the decode cache is wiped per setDocument | The pull loader must be a lookup, never a fetch or an encode |
| getNamedVariables() only lists operations reachable at inflation | Declare every slot at the document root, or overrides silently no-op |
| Integer expressions freeze at bake time | Never derive an int server-side |
It's alpha and it shows. Text doesn't scale in DP mode, there's no per-user gradient, lists have to be bounded, and the animation switches depend on internals that could be renamed in the next release.
None of that is the idea breaking. It's a young library, and the missing pieces are the kind you'd hope it grows into.
So it's a bet. Ship it on a screen that earns the effort, watch each release, and hope it settles.