Client-Heavy vs Server-Heavy Architectures for Mobile

Dhruval Dhameliya·September 27, 2025·8 min read

Comparing client-heavy and server-heavy mobile architectures across performance, maintainability, update velocity, and user experience trade-offs.

Every feature has a split point: where does the logic live? The client or the server? This decision affects update velocity, offline capability, performance, testability, and operational burden. This post lays out a framework for making that decision consistently.

Related: Designing a Feature Flag and Remote Config System.

Context

Mobile apps must decide, for each feature, how much logic runs on the device versus the server. A client-heavy approach puts business logic, validation, and data transformation on the device. A server-heavy approach keeps the client thin, rendering server-driven UI or acting as a display layer for server-computed results.

Problem

Define clear criteria for deciding when logic should live on the client versus the server, accounting for:

  • Update velocity (how fast can you change behavior?)
  • Offline capability
  • Performance (latency, battery, data usage)
  • Testability and debuggability
  • Consistency across platforms

Constraints

ConstraintDetail
Update cycleApp store updates take 1-7 days; server deploys take minutes
Device diversityLow-end devices have 1-2GB RAM and slow CPUs
Offline requirementSome features must work without connectivity
Platform countAndroid, iOS, potentially web; logic duplication is a real cost
RegulatorySome computations (e.g., tax calculation) must use server-authoritative values

Design

The Spectrum

This is not a binary choice. Most systems sit on a spectrum:

PatternClient RoleServer RoleExample
Thin clientRender UIAll logic, data, UI structureServer-driven UI (SDUI)
Smart clientUI logic, local stateBusiness logic, validationTypical REST API app
Thick clientBusiness logic, offlineStorage, sync, authOffline-first apps
PeerFull logicSync and backupLocal-first apps

Decision Framework

For each feature, evaluate these dimensions:

DimensionFavors ClientFavors Server
Update frequencyRarely changesChanges weekly or faster
Offline requirementMust work offlineOnline-only acceptable
Computation costLightweight (validation, formatting)Heavy (ML inference, aggregation)
Platform countSingle platformMulti-platform
Data sensitivityNon-sensitive display logicBusiness rules, pricing, auth
Latency sensitivityMust respond in < 100msCan tolerate 200ms+ network call
Regulatory requirementNo regulatory constraintMust be server-authoritative

Client-Heavy Architecture

Server: Simple CRUD API, raw data delivery
Client: Business logic, validation, caching, UI state management
// Client-side price calculation (client-heavy)
class PriceCalculator(
    private val taxRules: TaxRuleRepository,
    private val discountEngine: DiscountEngine
) {
    fun calculateTotal(cart: Cart, userLocation: Location): PriceBreakdown {
        val subtotal = cart.items.sumOf { it.price * it.quantity }
        val discount = discountEngine.apply(cart, userLocation)
        val tax = taxRules.calculate(subtotal - discount, userLocation)
 
        return PriceBreakdown(
            subtotal = subtotal,
            discount = discount,
            tax = tax,
            total = subtotal - discount + tax
        )
    }
}

Characteristics:

  • Fast response (no network round trip for calculations)
  • Works offline
  • Logic must be reimplemented per platform
  • Difficult to update without app release
  • Harder to enforce consistency (client can be tampered with)

Server-Heavy Architecture

Server: Business logic, validation, UI composition, data transformation
Client: Render server responses, handle user input
// Server-driven UI rendering (server-heavy)
class ServerDrivenRenderer(
    private val componentRegistry: Map<String, ViewFactory>
) {
    fun render(serverResponse: ScreenDefinition, parent: ViewGroup) {
        for (component in serverResponse.components) {
            val factory = componentRegistry[component.type]
                ?: componentRegistry["fallback"]!!
 
            val view = factory.create(parent.context, component.props)
            parent.addView(view)
        }
    }
}
 
// Server response
data class ScreenDefinition(
    val components: List<UIComponent>
)
 
data class UIComponent(
    val type: String,       // "product_card", "banner", "carousel"
    val props: Map<String, Any>,
    val actions: List<Action>
)

Characteristics:

  • Logic changes deploy in minutes
  • Single implementation on server
  • Requires network for most operations
  • Harder to deliver native-feeling interactions
  • Higher latency for each user action

Hybrid Approach (Recommended)

Most production systems use a hybrid:

LayerLocationRationale
UI renderingClientNative feel, animations, gestures
Input validationBothClient for UX, server for security
Business rulesServerSingle source of truth, fast updates
Pricing and taxServerRegulatory, consistency
CachingClientOffline access, reduced latency
Search and filteringServer (with client cache)Requires full dataset
NavigationClientInstant response, offline capable
Feature gatingServer config, client evaluationUpdate without release

Server-Driven UI with Client Augmentation

See also: Comparing Search Implementations: Client vs Server.

class HybridScreenRenderer(
    private val apiClient: ApiClient,
    private val localCache: ScreenCache,
    private val clientEnhancements: Map<String, ClientEnhancement>
) {
    suspend fun loadScreen(screenId: String): RenderedScreen {
        // 1. Show cached version immediately
        val cached = localCache.get(screenId)
        if (cached != null) display(cached)
 
        // 2. Fetch latest from server
        val serverScreen = try {
            apiClient.getScreen(screenId)
        } catch (e: IOException) {
            return cached ?: RenderedScreen.error("Unable to load")
        }
 
        // 3. Apply client-side enhancements (animations, gestures)
        val enhanced = clientEnhancements[screenId]?.enhance(serverScreen) ?: serverScreen
 
        // 4. Cache and display
        localCache.put(screenId, enhanced)
        return display(enhanced)
    }
}

Trade-offs

DecisionUpsideDownside
Client-heavyFast UX, offline worksSlow to update, multi-platform duplication
Server-heavyFast iteration, single logicNetwork dependent, higher latency
HybridBest of bothComplexity of managing the split point
Server-driven UIMaximum flexibilityLoss of native feel, accessibility challenges
Client-side validationInstant feedbackMust duplicate on server for security

Failure Modes

  • Server-heavy with offline users: App becomes unusable. Mitigation: cache last-known-good server responses and serve stale data with a refresh indicator.
  • Client-heavy with logic bugs: Bug requires an app update that takes days to propagate. Mitigation: gate client-side logic behind feature flags that can redirect to server-side fallback.
  • Server-driven UI accessibility: Dynamic layouts may not properly support screen readers. Mitigation: include accessibility metadata in the server response (content descriptions, roles, traversal order).
  • Validation mismatch: Client allows an input the server rejects. Mitigation: server is always authoritative. Client validation is a UX optimization, never a security measure.
  • Platform drift: iOS and Android client-heavy implementations diverge subtly. Mitigation: shared test suites that validate both platforms produce identical results for identical inputs.

Scaling Considerations

  • Server-heavy architectures shift load from client devices to your servers. At 10M DAU, that is significant compute cost.
  • Client-heavy reduces server load but increases client-side complexity and testing surface.
  • Server-driven UI requires a robust component versioning system. Old clients must handle new component types gracefully (fallback components).
  • For global apps, server-heavy increases sensitivity to server region latency. Client-heavy is naturally "edge-deployed" on every device.

Observability

  • Track: screen load time (client rendering + network fetch), error rates by architecture pattern, feature flag evaluation for logic routing, offline usage patterns.
  • Compare: metrics between client-heavy and server-heavy implementations of the same feature (if A/B tested).
  • Alert on: server-driven UI rendering failures (unknown component types), client-server validation mismatch rates.

Key Takeaways

  • Default to server for business logic, pricing, and anything that changes frequently or must be authoritative.
  • Default to client for UI rendering, input validation (as UX optimization), caching, and anything that must work offline.
  • Use a decision framework, not gut feeling. Evaluate each feature against the dimensions: update frequency, offline need, computation cost, platform count, data sensitivity.
  • Server-driven UI is powerful but comes with accessibility, testing, and native-feel trade-offs. Use it for dynamic content sections, not entire apps.
  • Client validation must never be trusted as the security boundary. Validate everything on the server.

Further Reading

Final Thoughts

The client-server split is the most consequential architectural decision in a mobile app. It determines how fast you can iterate, how well you handle offline, and how much you spend on infrastructure. Make it deliberately, per feature, with clear criteria. The worst outcome is an inconsistent split driven by whichever engineer implemented each feature.

Recommended