Sitemap

Beyond Hardcoded Layouts: Building Server-Driven UI with Ktor & Jetpack Compose

6 min readSep 7, 2026

--

How we decoupled mobile deployments from Google Play store approval cycles using Kotlin full-stack architecture, WebSockets, and Jetpack Compose.

Press enter or click to view image in full size
Real-time Remote Compose UI update triggered from Postman over WebSockets into Jetpack Compose with smooth color & text animations.

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 View hierarchies.
  • Remote Compose (SDUI): Represents the modern Kotlin-native implementation. By leveraging Jetpack Compose’s declarative UI model, server schemas map 1-to-1 with @Composable functions, 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?”

Press enter or click to view image in full size
Press enter or click to view image in full size

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:

  1. HTTP GET: Serves the initial SDUI state immediately (<10ms<10ms) on app launch.
  2. 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:

  1. Shared DTO Contracts: The same Kotlin @Serializable data classes (RemoteButtonDto) are shared between the Ktor server and Android client.
  2. Lightweight Asynchronous Engine: Built on Kotlin Coroutines, Ktor handles thousands of concurrent WebSocket client connections with minimal memory footprint.
  3. Seamless Serialization: Native kotlinx.serialization integration 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

  1. Zero Socket Leaks: Configured a single @Singleton HttpClient using Koin DI to prevent thread pool inflation and socket exhaustion.
  2. Resilient Network Security: Restricted cleartext HTTP traffic strictly to local development loopback domains via Android network_security_config.xml.
  3. Ultra-Smooth UX: Combined Compose animateColorAsState, animateContentSize, and AnimatedContent so 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

--

--