← Back to Blog

March 14, 2026 · 12 min read

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.

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.

┌─────────────────────────────────────────────┐ │ App │ ├─────────────────────────────────────────────┤ │ ┌─────────────┐ ┌─────────────────┐ │ │ │ Bundled │ │ Server-Driven │ │ │ │ Components │ │ Components │ │ │ ├─────────────┤ ├─────────────────┤ │ │ │ • Login │ │ • Home Feed │ │ │ │ • Checkout │ │ • Profile │ │ │ │ • Error │ │ • Promotions │ │ │ │ States │ │ • Settings │ │ │ └─────────────┘ └─────────────────┘ │ │ ↓ ↓ │ │ Always works Can fallback │ │ to bundled │ └─────────────────────────────────────────────┘

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.

┌────────────────────────────────┐ │ Header (OK) │ ├────────────────────────────────┤ │ Featured Products (OK) │ ├────────────────────────────────┤ │ ╳ Recommendations (FAILED) │ ← Hidden, not crashed ├────────────────────────────────┤ │ Categories (OK) │ └────────────────────────────────┘

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

Server sends: { "type": "NEW_CAROUSEL_V2", ← App version 1.0 doesn't know this "props": { ... } } App 1.0 component registry: ├── TEXT ✓ ├── BUTTON ✓ ├── IMAGE ✓ ├── CAROUSEL ✓ └── NEW_CAROUSEL_V2 ✗ (Unknown!)

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:

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