SDUI Error Handling & Fallbacks: Building Resilient Server-Driven Apps
Server-driven UI shifts control to the backend, but what happens when the server is unreachable? Learn how to build resilient SDUI apps that handle errors gracefully.
In This Article
1. Failure Modes in SDUI
Server-driven UI introduces a new category of failure modes that don't exist in traditional bundled apps. Understanding these failures is the first step to handling them.
Common SDUI Failure Scenarios
| Failure Type | Cause | Impact |
|---|---|---|
| Network timeout | Slow/unreachable server | Blank screen or spinner |
| Invalid JSON | Server bug, CDN corruption | Parse error, crash |
| Schema mismatch | New component not registered | Missing UI element |
| Authentication failure | Expired token | No personalized content |
| Rate limiting | Too many requests | Service unavailable |
| Stale cache | Aggressive caching | Outdated UI |
⚠️ The Blank Screen Problem
Unlike traditional apps where UI is bundled, SDUI apps can show a completely blank screen if the server fails. This is the #1 concern teams have when evaluating SDUI.
2. Fallback Strategies
Every SDUI implementation needs a fallback strategy. Here are the main approaches:
Strategy 1: Bundled Default Layout
Ship a minimal "last known good" layout bundled with the app. If the server fails, render this instead.
class SDUIRenderer {
private val bundledFallback = loadBundledLayout("fallback.json")
suspend fun loadScreen(route: String): ScreenLayout {
return try {
// Try to fetch from server
api.fetchLayout(route)
} catch (e: NetworkException) {
// Fall back to bundled layout
when {
cache.has(route) -> cache.get(route)
else -> bundledFallback
}
}
}
}
Pros: Always shows something. No blank screens.
Cons: Bundled layout can become stale. Adds to app size.
Strategy 2: Cached-First with Background Refresh
Show cached content immediately, fetch fresh content in the background.
suspend fun loadWithBackgroundRefresh(route: String): Flow<ScreenLayout> = flow {
// Emit cached version immediately
cache.get(route)?.let { emit(it) }
// Fetch fresh in background
try {
val fresh = api.fetchLayout(route)
cache.put(route, fresh)
emit(fresh)
} catch (e: Exception) {
// If we already emitted cache, silently fail
if (!cache.has(route)) {
throw e
}
}
}
Pros: Instant load times. Graceful offline support.
Cons: Users may see outdated content briefly.
Strategy 3: Hybrid Components
Mix server-driven and bundled UI. Critical paths (login, checkout) are bundled; secondary screens are server-driven.
3. Caching for Resilience
Effective caching is your first line of defense against network failures.
Multi-Layer Cache Architecture
class SDUICache(context: Context) {
// L1: In-memory LRU cache (instant access)
private val memoryCache = LruCache<String, ScreenLayout>(50)
// L2: Disk cache (survives app restart)
private val diskCache = DiskLruCache.open(
context.cacheDir, 1, 1, 50 * 1024 * 1024 // 50MB
)
// L3: Bundled assets (always available)
private val bundledLayouts = loadBundledAssets()
fun get(route: String): ScreenLayout? {
// Check L1 → L2 → L3
return memoryCache.get(route)
?: diskCache.get(route)?.also { memoryCache.put(route, it) }
?: bundledLayouts[route]
}
}
Cache Invalidation Strategies
| Strategy | When to Use | Trade-off |
|---|---|---|
| Time-based (TTL) | Content changes infrequently | Simple but may show stale data |
| Version-based | Backend can signal versions | More network calls to check version |
| ETag/Last-Modified | HTTP-compliant systems | Best of both worlds |
| Push-based | Real-time apps (WebSocket) | Complexity, connection overhead |
4. Graceful Degradation Patterns
When things go wrong, degrade gracefully instead of breaking completely.
Pattern 1: Component-Level Fallbacks
If a single component fails to render, show a placeholder instead of crashing the whole screen.
@Composable
fun SDUIComponent(node: JsonNode) {
val component = componentRegistry.get(node.type)
if (component == null) {
// Unknown component type - render placeholder
UnknownComponentPlaceholder(
type = node.type,
debugMode = BuildConfig.DEBUG
)
} else {
try {
component.render(node.props)
} catch (e: Exception) {
// Component crashed - show error boundary
ComponentErrorBoundary(
error = e,
fallback = { Spacer() }
)
}
}
}
Pattern 2: Section-Level Degradation
If a section of the page fails, hide it rather than failing the whole page.
Pattern 3: Reduced Functionality Mode
If the server is down, offer a read-only or limited mode.
sealed class AppMode {
object Full : AppMode()
object ReadOnly : AppMode() // Can browse, can't interact
object Offline : AppMode() // Cached content only
}
@Composable
fun ActionButton(action: Action, appMode: AppMode) {
Button(
onClick = { performAction(action) },
enabled = appMode == AppMode.Full
) {
if (appMode != AppMode.Full) {
Icon(Icons.Offline)
}
Text(action.label)
}
}
5. Handling Schema Mismatches
Schema mismatches happen when the server sends a component type that the app doesn't recognize. This is common during rollouts.
The Problem
Solution 1: Version Negotiation
// Client tells server which components it supports
GET /api/layout/home
X-SDUI-Version: 1.0
X-Supported-Components: TEXT,BUTTON,IMAGE,CAROUSEL
// Server responds with compatible layout
200 OK
Content-Type: application/json
{
"type": "CAROUSEL", // Falls back to V1
"props": { ... }
}
Solution 2: Fallback Component Mapping
val fallbackMap = mapOf(
"NEW_CAROUSEL_V2" to "CAROUSEL",
"FANCY_BUTTON" to "BUTTON",
"RICH_TEXT_V3" to "TEXT"
)
fun resolveComponent(type: String): ComponentRenderer? {
return componentRegistry.get(type)
?: componentRegistry.get(fallbackMap[type])
}
Solution 3: Server-Side Feature Flags
// Server checks client version before serving new components
if (clientVersion >= "2.0") {
return NewCarouselV2()
} else {
return ClassicCarousel()
}
6. Retry & Circuit Breaker Patterns
Don't hammer a failing server. Use smart retry strategies.
Exponential Backoff
suspend fun fetchWithRetry(
route: String,
maxRetries: Int = 3,
baseDelay: Long = 1000L
): ScreenLayout {
var attempt = 0
var delay = baseDelay
while (attempt < maxRetries) {
try {
return api.fetchLayout(route)
} catch (e: NetworkException) {
attempt++
if (attempt >= maxRetries) throw e
delay(delay)
delay *= 2 // Exponential backoff
}
}
throw IllegalStateException()
}
Circuit Breaker
If a service fails repeatedly, stop calling it temporarily.
class CircuitBreaker(
private val failureThreshold: Int = 5,
private val resetTimeout: Long = 60_000L
) {
private var failures = 0
private var state = State.CLOSED
private var lastFailure = 0L
suspend fun <T> execute(block: suspend () -> T): T {
when (state) {
State.OPEN -> {
if (System.currentTimeMillis() - lastFailure > resetTimeout) {
state = State.HALF_OPEN
} else {
throw CircuitOpenException()
}
}
else -> {}
}
return try {
block().also { onSuccess() }
} catch (e: Exception) {
onFailure()
throw e
}
}
private fun onSuccess() {
failures = 0
state = State.CLOSED
}
private fun onFailure() {
failures++
lastFailure = System.currentTimeMillis()
if (failures >= failureThreshold) {
state = State.OPEN
}
}
}
7. Monitoring & Alerting
You can't fix what you can't see. Monitor SDUI-specific metrics.
Key Metrics to Track
| Metric | What It Tells You | Alert Threshold |
|---|---|---|
| Layout fetch success rate | Server reliability | < 99% |
| Cache hit rate | Cache effectiveness | < 80% |
| Unknown component rate | Schema mismatches | > 1% |
| Fallback usage rate | How often fallbacks are used | > 5% |
| Layout render time | Performance | P95 > 500ms |
| Component render errors | Component bugs | > 0.1% |
Example: Analytics Events
// Track SDUI-specific events
analytics.track("sdui_layout_fetch", mapOf(
"route" to route,
"success" to success,
"source" to source, // "server", "cache", "fallback"
"latencyMs" to latency
))
analytics.track("sdui_component_error", mapOf(
"componentType" to type,
"error" to error.message,
"stackTrace" to error.stackTrace
))
8. Testing Error Scenarios
Don't wait for production to discover error handling bugs. Test proactively.
Chaos Testing for SDUI
class ChaosSDUIClient(
private val realClient: SDUIClient,
private val chaosConfig: ChaosConfig
) : SDUIClient {
override suspend fun fetchLayout(route: String): ScreenLayout {
// Randomly inject failures
if (Random.nextFloat() < chaosConfig.failureRate) {
throw NetworkException("Chaos monkey!")
}
// Add random latency
if (chaosConfig.addLatency) {
delay(Random.nextLong(500, 3000))
}
val layout = realClient.fetchLayout(route)
// Corrupt random components
if (Random.nextFloat() < chaosConfig.corruptionRate) {
return layout.corruptRandomComponent()
}
return layout
}
}
Unit Tests for Error Paths
@Test
fun `shows cached content when server fails`() = runTest {
// Given: cached content exists
cache.put("/home", cachedLayout)
api.stubFailure(NetworkException())
// When: loading the screen
val result = renderer.loadScreen("/home")
// Then: shows cached content
assertEquals(cachedLayout, result)
}
@Test
fun `renders placeholder for unknown component`() {
val unknownNode = JsonNode(type = "FUTURE_COMPONENT", props = emptyMap())
composeTestRule.setContent {
SDUIComponent(unknownNode)
}
composeTestRule.onNodeWithTag("unknown-component-placeholder")
.assertIsDisplayed()
}
✅ Best Practice: Test the Unhappy Paths
For every SDUI screen, write tests for: network failure, timeout, invalid JSON, unknown component, and cache miss. These are the scenarios that matter most in production.
Summary: Error Handling Checklist
Before shipping SDUI to production, ensure you have:
- ✅ Multi-layer caching (memory → disk → bundled)
- ✅ Fallback layouts for critical screens
- ✅ Component-level error boundaries
- ✅ Schema mismatch handling
- ✅ Retry with exponential backoff
- ✅ Circuit breaker for repeated failures
- ✅ Analytics for error rates and fallback usage
- ✅ Chaos testing in QA environments
Build Resilient SDUI Apps with Pyramid
Pyramid includes built-in caching, fallback strategies, and error handling out of the box.
Join the Waitlist →Further Reading
Related Articles
- SDUI Architecture Patterns: Building Scalable Server-Driven UI Systems →
- SDUI Performance Optimization: Making Server-Driven UI Fast →
- Server-Driven UI Tutorial: Building Dynamic Screens with Jetpack Compose →
- Server-Driven UI Tutorial: Building Dynamic Screens with SwiftUI →
- Server-Driven UI for Android: A Complete Implementation Guide →