Client-Heavy vs Server-Heavy Architectures for Mobile
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
| Constraint | Detail |
|---|---|
| Update cycle | App store updates take 1-7 days; server deploys take minutes |
| Device diversity | Low-end devices have 1-2GB RAM and slow CPUs |
| Offline requirement | Some features must work without connectivity |
| Platform count | Android, iOS, potentially web; logic duplication is a real cost |
| Regulatory | Some 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:
| Pattern | Client Role | Server Role | Example |
|---|---|---|---|
| Thin client | Render UI | All logic, data, UI structure | Server-driven UI (SDUI) |
| Smart client | UI logic, local state | Business logic, validation | Typical REST API app |
| Thick client | Business logic, offline | Storage, sync, auth | Offline-first apps |
| Peer | Full logic | Sync and backup | Local-first apps |
Decision Framework
For each feature, evaluate these dimensions:
| Dimension | Favors Client | Favors Server |
|---|---|---|
| Update frequency | Rarely changes | Changes weekly or faster |
| Offline requirement | Must work offline | Online-only acceptable |
| Computation cost | Lightweight (validation, formatting) | Heavy (ML inference, aggregation) |
| Platform count | Single platform | Multi-platform |
| Data sensitivity | Non-sensitive display logic | Business rules, pricing, auth |
| Latency sensitivity | Must respond in < 100ms | Can tolerate 200ms+ network call |
| Regulatory requirement | No regulatory constraint | Must 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:
| Layer | Location | Rationale |
|---|---|---|
| UI rendering | Client | Native feel, animations, gestures |
| Input validation | Both | Client for UX, server for security |
| Business rules | Server | Single source of truth, fast updates |
| Pricing and tax | Server | Regulatory, consistency |
| Caching | Client | Offline access, reduced latency |
| Search and filtering | Server (with client cache) | Requires full dataset |
| Navigation | Client | Instant response, offline capable |
| Feature gating | Server config, client evaluation | Update 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
| Decision | Upside | Downside |
|---|---|---|
| Client-heavy | Fast UX, offline works | Slow to update, multi-platform duplication |
| Server-heavy | Fast iteration, single logic | Network dependent, higher latency |
| Hybrid | Best of both | Complexity of managing the split point |
| Server-driven UI | Maximum flexibility | Loss of native feel, accessibility challenges |
| Client-side validation | Instant feedback | Must 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
- Trade-offs in Push vs Pull Architectures for Mobile: A comparison of push and pull architectures for mobile data delivery, covering WebSockets, SSE, polling, push notifications, and hybrid a...
- Mobile Analytics Pipeline: From App Event to Dashboard: End-to-end design of a mobile analytics pipeline covering ingestion, processing, storage, and querying, with emphasis on reliability and ...
- Comparing REST vs GraphQL for Mobile Clients: Measured payload sizes, request counts, latency, and battery impact of REST vs GraphQL APIs serving a mobile application with varying net...
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
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.