Trade-offs in Push vs Pull Architectures for Mobile

Dhruval Dhameliya·August 1, 2025·9 min read

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

ConstraintDetail
BatteryMaintaining a persistent connection costs battery; polling wastes battery on no-change responses
ConnectionsEach WebSocket/SSE connection consumes server memory and a file descriptor
Background restrictionsAndroid kills background sockets; iOS suspends apps aggressively
Data freshnessRanges from seconds (chat) to minutes (feed) to hours (settings)
Scale1M-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)
        }
    }
}
AspectDetail
FreshnessBounded by poll interval (worst case = interval duration)
BatteryWakes radio every interval, even when no updates exist
Server costEvery client hits the server every interval, regardless of changes
SimplicitySimplest to implement and debug
BackgroundWorks 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
AspectDetail
FreshnessNear-real-time (delivered as soon as change occurs)
BatteryBetter than polling (fewer responses with no data)
Server costHolds connections open, consumes threads/memory
SimplicityModerate; requires connection management on both sides
BackgroundUnreliable 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") }
    }
}
AspectDetail
FreshnessReal-time (sub-second delivery)
BatteryPersistent connection keeps radio active; heartbeats consume battery
Server costEach connection consumes ~10-50KB memory; 1M connections = 10-50GB RAM
SimplicityComplex; reconnection logic, heartbeats, state synchronization
BackgroundKilled 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.

AspectDetail
FreshnessReal-time
BatterySimilar to WebSocket (persistent connection)
Server costLower than WebSocket (unidirectional, HTTP-based)
SimplicitySimpler than WebSocket (no bidirectional framing)
BackgroundSame 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()
        }
    }
}
AspectDetail
FreshnessSeconds (varies by provider; FCM is best-effort)
BatteryEfficient; uses shared system connection
Server costLow; no per-client connections
SimplicityModerate; requires push provider integration
BackgroundWorks in background (with limitations on data payload processing)

Comparison Matrix

CriteriaPollingLong PollingWebSocketSSEPush Notification
FreshnessMinutesSecondsSub-secondSub-secondSeconds
Battery impactHighMediumHighHighLow
Server connectionsNone (stateless)Held openPersistentPersistentNone (uses FCM)
Background supportGood (WorkManager)PoorPoorPoorGood
BidirectionalNoNoYesNoNo
Scale (connections)ExcellentModerateChallengingModerateExcellent
ReliabilityHighMediumMediumMediumMedium (best-effort)

Hybrid Architecture (Recommended)

Most production apps use a combination:

ScenarioMechanismRationale
App in foreground, real-time feature (chat)WebSocketSub-second delivery needed
App in foreground, near-real-time (feed)Polling (30s) or SSEAcceptable delay, simpler
App in backgroundPush notification triggers syncBattery-efficient, OS-friendly
App opened after long idlePull on app foregroundCatch up on missed updates
Config/flag changesPush notification triggers config refreshInfrequent, 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

DecisionUpsideDownside
PollingSimple, stateless, reliableWasteful bandwidth, delayed freshness
WebSocketReal-time, bidirectionalComplex, battery-heavy, hard to scale
Push notificationBattery-efficient, works in backgroundUnreliable delivery, payload size limits
Hybrid approachBest of each for each scenarioComplexity of managing multiple mechanisms
Lifecycle-aware switchingOptimizes for current app stateState 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

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