Beyond Hardcoded Layouts: Building Server-Driven UI with Ktor & Jetpack Compose
How we decoupled mobile deployments from Google Play store approval cycles using Kotlin full-stack architecture, WebSockets, and Jetpack Compose.
Imagine launching a high-stakes Flash Sale or an urgent promotional banner in your mobile app.
Under the traditional mobile development lifecycle, you update the XML/Compose layout, compile an APK, submit it to the Google Play Console, and wait anywhere from 4 to 48 hours for review — hoping your users actually tap Update.
What if your app didn’t need a store release to change its user interface? What if your backend could dictate not just what data to show, but how the UI looks, animates, and behaves in real time?
Get Ozlembasabakar’s stories in your inbox
Join Medium for free to get updates from this writer.
Welcome to Server-Driven UI (SDUI) — the architectural paradigm powering top-tier mobile apps at Airbnb, Uber, and Lyft. In this article, we’ll dive into a full-stack Kotlin implementation of SDUI (Remote Compose) using Ktor on the backend and Jetpack Compose on Android.
1. The Core Concept: A Simple Mental Model
If you’ve never encountered SDUI before, think of it using a Theatre Director & Actor analogy:
🎭 Traditional Mobile Development: The Android app is a theater actor who has memorized a rigid script (hardcoded Layouts & Views). If the director wants the actor to wear a red jacket or step to the right, the show must be canceled, re-rehearsed, and re-staged (App Release Cycle).
🎬 Server-Driven UI (Remote Compose): The Android app is an actor wearing an earpiece. The backend director speaks into the microphone: “Render a rounded gradient button, make it magenta-to-purple, set the text to ‘⚡ Flash Sale’, and handle clicks by opening
app://action/sale.” The actor instantly performs the direction in real time—zero script memorization required.
In short, the server becomes the UI orchestrator, while Jetpack Compose acts as the high-performance native rendering engine.
2. SDUI vs. BDUI (Backend-Driven UI)
You might hear developers use Backend-Driven UI (BDUI) and Server-Driven UI (SDUI) interchangeably. Are they different?
- The Paradigm: Architecturally, BDUI and SDUI represent the exact same paradigm shift — shifting UI structure, layout composition, and action wiring from the client to the server.
- The Modern Evolution: Historically, BDUI was implemented using rigid XML payloads, web view wrappers, or heavy JSON trees mapped to legacy Android
Viewhierarchies. - Remote Compose (SDUI): Represents the modern Kotlin-native implementation. By leveraging Jetpack Compose’s declarative UI model, server schemas map 1-to-1 with
@Composablefunctions, enabling reactive color morphing, smooth animations, and zero-view-tree overhead.
3. SDUI vs. Feature Flags / Firebase Remote Config
A common question in system design interviews is: “Why can’t we just use Firebase Remote Config or Feature Flags?”
FeatureFeature Flags / Remote ConfigServer-Driven UI (SDUI)Primary PurposeToggling pre-written code paths (true/false)Dynamically building UI components & layoutsNew Component Creation❌ Impossible (Requires new APK release)✅ Instant (Server dictates schema & styling)Styling & Theme Changes⚠️ Limited to pre-defined config keys✅ Full control over gradients, colors & actionsCouplingHigh (Client holds all layout logic)Low (Client is a pure rendering engine)
💡 Key Insight: Feature Flags toggle existing code. SDUI delivers new UI capabilities without writing a single line of client-side layout code.
4. SDUI vs. Standard Polling & Kotlin Flow Data Fetching
Isn’t SDUI just streaming data over a Kotlin Flow? Not quite.
- Standard Data Fetching: The API returns domain data (e.g.
{"price": 49.99, "stock": 5}). The Android app decides where to place the text, what font size to use, and what color background to draw. - Server-Driven UI: The API returns UI contract definitions (e.g.
{"text": "Buy Now", "gradientColorsHex": ["#FF007F", "#7F00FF"]}). The server controls visual presentation and layout structure.
📌 Architectural Shift: Traditional APIs transfer Domain Entities. SDUI APIs transfer Presentation Contracts.
5. Protocol vs. Pattern: WebSockets & SDUI
It is vital to distinguish between a Transport Protocol and an Architectural Pattern:
- SDUI is an Architectural Pattern: It dictates what is being communicated (Server controls UI layout schemas).
- WebSockets is a Transport Protocol: It dictates how messages move (a bi-directional, persistent push channel).
In our project, we combined both:
- HTTP GET: Serves the initial SDUI state immediately (<10ms<10ms) on app launch.
- WebSockets Stream (
ws://): Establishes a live push connection (/api/v1/button-config/stream). Whenever a marketing manager posts a new UI state via Postman, the server pushes the updated JSON schema over WebSockets, and the Android screen morphs instantly!
6. Full-Stack Synergy: Why Ktor on the Backend?
Using Ktor for our backend provided immense full-stack Kotlin advantages:
- Shared DTO Contracts: The same Kotlin
@Serializabledata classes (RemoteButtonDto) are shared between the Ktor server and Android client. - Lightweight Asynchronous Engine: Built on Kotlin Coroutines, Ktor handles thousands of concurrent WebSocket client connections with minimal memory footprint.
- Seamless Serialization: Native
kotlinx.serializationintegration eliminates heavy reflection libraries like Jackson or Gson.
7. The End-to-End Request & Rendering Lifecycle
Here is the exact lifecycle of a Remote Compose update across our full-stack architecture:
8. Code Showcase: Under the Hood
Backend: Ktor Route Definition (ButtonRoute.kt)
@Serializable
data class RemoteButtonDto(
val text: String = "Remote Button",
val backgroundColorHex: String? = null,
val textColorHex: String? = "#FFFFFF",
val enabledState: Boolean = true,
val actionUrl: String? = null,
val gradientColorsHex: List<String>? = null
)fun Route.buttonRoutes() {
route("/api/v1/button-config") {
get {
call.respond(HttpStatusCode.OK, currentButtonConfig)
}
post {
val newConfig = call.receive<RemoteButtonDto>()
broadcastState(newConfig) // Pushes to all active WebSocket sessions
call.respond(HttpStatusCode.OK, currentButtonConfig)
}
webSocket("/stream") {
activeSessions.add(this)
// Real-time push session handling
}
}
}Android Client: Pure Native Compose Renderer (RemoteButtonScreen.kt)
@Composable
fun RemoteDrivenButton(
model: ButtonUiModel,
onClick: () -> Unit,
) {
val hasGradient = !model.gradientColors.isNullOrEmpty() && model.gradientColors.size >= 2
val startColor = if (hasGradient) model.gradientColors[0] else model.backgroundColor
val endColor = if (hasGradient) model.gradientColors[1] else model.backgroundColor
val animatedStartColor by animateColorAsState(targetValue = startColor, animationSpec = tween(500))
val animatedEndColor by animateColorAsState(targetValue = endColor, animationSpec = tween(500))
val animatedTextColor by animateColorAsState(targetValue = model.textColor, animationSpec = tween(500))
val buttonShape = RoundedCornerShape(12.dp)
val backgroundModifier = if (hasGradient) {
Modifier.background(Brush.horizontalGradient(listOf(animatedStartColor, animatedEndColor)), buttonShape)
} else {
Modifier.background(animatedStartColor, buttonShape)
}
Box(
modifier = Modifier
.shadow(6.dp, buttonShape)
.then(backgroundModifier)
.clip(buttonShape)
.clickable(enabled = model.enabled, onClick = onClick)
.animateContentSize(animationSpec = tween(500))
.padding(horizontal = 32.dp, vertical = 20.dp),
contentAlignment = Alignment.Center
) {
AnimatedContent(
targetState = model.text,
transitionSpec = {
(fadeIn(tween(400)) + slideInVertically(tween(400)) { h -> h / 2 }) togetherWith
(fadeOut(tween(400)) + slideOutVertically(tween(400)) { h -> -h / 2 })
}
) { targetText ->
Text(text = targetText, color = animatedTextColor, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
}9. Key Architectural Takeaways
- Zero Socket Leaks: Configured a single
@SingletonHttpClientusing Koin DI to prevent thread pool inflation and socket exhaustion. - Resilient Network Security: Restricted cleartext HTTP traffic strictly to local development loopback domains via Android
network_security_config.xml. - Ultra-Smooth UX: Combined Compose
animateColorAsState,animateContentSize, andAnimatedContentso UI updates morph gracefully without jarring flickers.
Conclusion
Server-Driven UI is not just a trend — it’s a fundamental shift in how modern engineering teams deliver instant value to users. By combining Ktor, WebSockets, and Jetpack Compose, we achieved a full-stack Kotlin Remote Compose system that eliminates Play Store delays while preserving 100% native rendering performance.
🔗 Project Repository & Artifacts
- GitHub Repository: RemoteComposeSample
- RemoteCompose Docs: remote compose







