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 approaches with their constraints.
Should the server push updates to the client, or should the client poll for changes? The answer depends on data freshness requirements, battery constraints, infrastructure cost, and how many concurrent connections you can sustain. This post breaks down the trade-offs systematically.
Context
Mobile apps need to display up-to-date information: chat messages, live scores, order status, stock prices, notification badges. The mechanism for delivering these updates, push or pull, has cascading effects on battery life, server cost, data freshness, and system complexity.
Problem
Choose and design a data delivery architecture that:
- Delivers updates with acceptable freshness for the use case
- Minimizes battery and bandwidth consumption
- Scales to millions of concurrent clients
- Handles mobile-specific challenges (Doze mode, background restrictions, network transitions)
Constraints
| Constraint | Detail |
|---|---|
| Battery | Maintaining a persistent connection costs battery; polling wastes battery on no-change responses |
| Connections | Each WebSocket/SSE connection consumes server memory and a file descriptor |
| Background restrictions | Android kills background sockets; iOS suspends apps aggressively |
| Data freshness | Ranges from seconds (chat) to minutes (feed) to hours (settings) |
| Scale | 1M-10M concurrent mobile clients |
Design
Architecture Options
1. Client Polling
The client periodically requests updates from the server.
class PollingDataSource(
private val api: ApiService,
private val interval: Long = 30_000 // 30 seconds
) {
fun startPolling(): Flow<DataUpdate> = flow {
while (true) {
try {
val data = api.getLatestData()
emit(data)
} catch (e: Exception) {
// Log, continue polling
}
delay(interval)
}
}
}| Aspect | Detail |
|---|---|
| Freshness | Bounded by poll interval (worst case = interval duration) |
| Battery | Wakes radio every interval, even when no updates exist |
| Server cost | Every client hits the server every interval, regardless of changes |
| Simplicity | Simplest to implement and debug |
| Background | Works with WorkManager for periodic background polls |
2. Long Polling
Client makes a request, server holds it open until data is available or timeout occurs.
long_poll(client_request):
last_known_version = client_request.header("X-Last-Version")
timeout = 30 seconds
wait_for_change(last_known_version, timeout):
if change_detected:
return new_data
if timeout:
return 304 Not Modified
client receives response, immediately re-opens connection
| Aspect | Detail |
|---|---|
| Freshness | Near-real-time (delivered as soon as change occurs) |
| Battery | Better than polling (fewer responses with no data) |
| Server cost | Holds connections open, consumes threads/memory |
| Simplicity | Moderate; requires connection management on both sides |
| Background | Unreliable in background on mobile |
3. WebSockets
Persistent bidirectional connection between client and server.
class WebSocketDataSource(
private val client: OkHttpClient,
private val url: String
) {
private var webSocket: WebSocket? = null
fun connect(): Flow<DataUpdate> = callbackFlow {
val listener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
val update = parseUpdate(text)
trySend(update)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// Reconnect with backoff
reconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
}
}
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, listener)
awaitClose { webSocket?.close(1000, "Flow cancelled") }
}
}| Aspect | Detail |
|---|---|
| Freshness | Real-time (sub-second delivery) |
| Battery | Persistent connection keeps radio active; heartbeats consume battery |
| Server cost | Each connection consumes ~10-50KB memory; 1M connections = 10-50GB RAM |
| Simplicity | Complex; reconnection logic, heartbeats, state synchronization |
| Background | Killed by OS in background; unreliable without foreground service |
4. Server-Sent Events (SSE)
Related: Event Tracking System Design for Android Applications.
Unidirectional server-to-client stream over HTTP.
| Aspect | Detail |
|---|---|
| Freshness | Real-time |
| Battery | Similar to WebSocket (persistent connection) |
| Server cost | Lower than WebSocket (unidirectional, HTTP-based) |
| Simplicity | Simpler than WebSocket (no bidirectional framing) |
| Background | Same limitations as WebSocket |
5. Push Notifications (FCM/APNs)
Server sends a push notification that wakes the app.
class PushTriggeredSync : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val syncType = message.data["sync_type"]
when (syncType) {
"new_message" -> MessageSyncWorker.enqueue(message.data["conversation_id"]!!)
"order_update" -> OrderSyncWorker.enqueue(message.data["order_id"]!!)
"config_update" -> ConfigRefreshWorker.enqueue()
}
}
}| Aspect | Detail |
|---|---|
| Freshness | Seconds (varies by provider; FCM is best-effort) |
| Battery | Efficient; uses shared system connection |
| Server cost | Low; no per-client connections |
| Simplicity | Moderate; requires push provider integration |
| Background | Works in background (with limitations on data payload processing) |
Comparison Matrix
| Criteria | Polling | Long Polling | WebSocket | SSE | Push Notification |
|---|---|---|---|---|---|
| Freshness | Minutes | Seconds | Sub-second | Sub-second | Seconds |
| Battery impact | High | Medium | High | High | Low |
| Server connections | None (stateless) | Held open | Persistent | Persistent | None (uses FCM) |
| Background support | Good (WorkManager) | Poor | Poor | Poor | Good |
| Bidirectional | No | No | Yes | No | No |
| Scale (connections) | Excellent | Moderate | Challenging | Moderate | Excellent |
| Reliability | High | Medium | Medium | Medium | Medium (best-effort) |
Hybrid Architecture (Recommended)
Most production apps use a combination:
| Scenario | Mechanism | Rationale |
|---|---|---|
| App in foreground, real-time feature (chat) | WebSocket | Sub-second delivery needed |
| App in foreground, near-real-time (feed) | Polling (30s) or SSE | Acceptable delay, simpler |
| App in background | Push notification triggers sync | Battery-efficient, OS-friendly |
| App opened after long idle | Pull on app foreground | Catch up on missed updates |
| Config/flag changes | Push notification triggers config refresh | Infrequent, critical |
class HybridDataSync(
private val webSocketSource: WebSocketDataSource,
private val pollingSource: PollingDataSource,
private val lifecycleOwner: LifecycleOwner
) {
fun observe(): Flow<DataUpdate> {
return lifecycleOwner.lifecycle.currentStateFlow
.flatMapLatest { state ->
when {
state.isAtLeast(Lifecycle.State.RESUMED) ->
webSocketSource.connect() // Real-time when visible
state.isAtLeast(Lifecycle.State.STARTED) ->
pollingSource.startPolling() // Poll when in foreground but not visible
else ->
emptyFlow() // Background: rely on push notifications
}
}
}
}WebSocket Connection Management
See also: Designing a Feature Flag and Remote Config System.
For apps that use WebSocket in the foreground:
class ManagedWebSocket(
private val url: String,
private val client: OkHttpClient
) {
private var reconnectAttempt = 0
private val maxReconnectDelay = 30_000L
fun connect() {
val request = Request.Builder().url(url).build()
client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
reconnectAttempt = 0
startHeartbeat(webSocket)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
scheduleReconnect()
}
})
}
private fun scheduleReconnect() {
val delay = minOf(
1000L * (1 shl reconnectAttempt) + Random.nextLong(1000),
maxReconnectDelay
)
reconnectAttempt++
handler.postDelayed({ connect() }, delay)
}
private fun startHeartbeat(webSocket: WebSocket) {
// Send ping every 30 seconds to detect dead connections
heartbeatJob = scope.launch {
while (isActive) {
delay(30_000)
if (!webSocket.send("ping")) {
webSocket.close(1000, "Heartbeat failed")
scheduleReconnect()
break
}
}
}
}
}Trade-offs
| Decision | Upside | Downside |
|---|---|---|
| Polling | Simple, stateless, reliable | Wasteful bandwidth, delayed freshness |
| WebSocket | Real-time, bidirectional | Complex, battery-heavy, hard to scale |
| Push notification | Battery-efficient, works in background | Unreliable delivery, payload size limits |
| Hybrid approach | Best of each for each scenario | Complexity of managing multiple mechanisms |
| Lifecycle-aware switching | Optimizes for current app state | State transitions can cause missed updates |
Failure Modes
- WebSocket reconnection storm: Server restart causes all clients to reconnect simultaneously. Mitigation: randomized reconnect delay (jitter), server-side connection rate limiting.
- Push notification not delivered: FCM is best-effort. Critical data should never depend solely on push. Mitigation: push triggers a pull; the pull is the source of truth.
- Polling overload: 1M clients polling every 30 seconds = 33K requests/second. Mitigation: randomize poll intervals, use conditional requests (ETags) to reduce response size.
- Stale data after background resume: App resumes from background with outdated data. Mitigation: always pull on foreground transition, regardless of push/pull strategy.
- WebSocket through proxies: Some corporate/carrier proxies drop WebSocket connections. Mitigation: detect WebSocket failure and fall back to long polling or standard polling automatically.
Scaling Considerations
- WebSocket at scale requires dedicated connection servers separate from application servers. Connection servers handle protocol, application servers handle logic.
- For 10M concurrent WebSocket connections, expect ~100-500GB of RAM for connection state across your fleet.
- Push notifications scale through the provider (FCM/APNs). Your server sends to the provider API, which handles delivery. This is the most scalable approach.
- Polling scales with standard HTTP infrastructure (CDN, load balancer, stateless servers). It is the easiest to scale, just the most wasteful.
Observability
- Track: update delivery latency by mechanism, WebSocket connection duration, reconnection rate, polling empty response rate, push delivery rate (sent vs. received).
- Alert on: WebSocket reconnection rate exceeding 10% per minute, polling empty response rate exceeding 95% (indicates interval is too aggressive), push delivery rate below 80%.
- Dashboard: real-time view of active connections by mechanism, data freshness distribution, bandwidth usage by mechanism.
Key Takeaways
- No single mechanism is optimal for all scenarios. Use a hybrid: WebSocket/SSE for foreground real-time, push for background, pull for catch-up.
- Push notifications should trigger pulls, not deliver data. The push is a signal; the pull is the source of truth.
- WebSocket connections must be lifecycle-aware on mobile. Open on foreground, close on background. Anything else wastes battery.
- Account for reconnection storms in your WebSocket architecture. Jitter and rate limiting on the server are mandatory.
- Measure the cost of each mechanism: battery, bandwidth, server resources. Choose based on data, not assumption.
Further Reading
- Client-Heavy vs Server-Heavy Architectures for Mobile: Comparing client-heavy and server-heavy mobile architectures across performance, maintainability, update velocity, and user experience tr...
- How I'd Design a Mobile Configuration System at Scale: Designing a configuration system for mobile apps at scale, covering config delivery, caching layers, override hierarchies, and safe rollo...
- Designing an Experimentation Platform for Mobile Apps: System design for a mobile experimentation platform covering assignment, exposure tracking, metric collection, statistical analysis, and ...
Final Thoughts
The push vs. pull decision is not architectural dogma. It is an engineering trade-off evaluated per feature, per use case, per user state. The right answer for a chat app is different from the right answer for a news feed. Design each data flow with its freshness requirement, battery budget, and scale target in mind.
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.