Most mobile apps treat the backend as the authority on connection state. The server holds the socket, the app polls or waits, and the whole thing falls apart the moment the backend process restarts or the framework's request lifecycle decides the party is over.
There is a quieter approach that holds up better in practice: let the native runtime own the persistent WebSocket connection entirely, and reduce the backend to a stateless responder.
The Problem with Backend-Owned Sockets
PHP is the easiest example to make this concrete, but the principle applies to any request-scoped backend runtime.
PHP's execution model is built around a lifecycle that starts when a request arrives and ends when the response is sent. Keeping a WebSocket alive inside that model means fighting the runtime. You end up reaching for extensions, workers, or long-running process managers just to get the socket to outlive a single request. And then you have to worry about what happens when you deploy, when the process crashes, or when you want to suspend the app on a user's device.
The socket becomes a liability the backend has to babysit rather than a channel the client naturally owns.
Flip the Ownership Model
When the native layer (Swift on iOS, Kotlin on Android) owns the WebSocket connection, the relationship between client and server changes in a useful way. The backend can render, restart, or redeploy without touching socket state. The connection persists across backend restarts because it lives on the device, not the server. The server's only job is to push events into a queue that the client drains when it asks.
This means the backend API surface for real-time becomes something like a simple long-poll or fetch endpoint, not a socket server at all. The server does not hold any per-client connection. It holds a queue.
Bounded Queues Are a Feature, Not a Limitation
One of the less obvious design decisions in this model is making the native event queue deliberately small. A 256-item cap sounds restrictive, but unbounded queues are a real failure mode. If the client goes offline for an extended period and the server keeps buffering events, you end up with memory growth that is hard to reason about and harder to recover from gracefully.
A bounded queue forces an explicit decision: what does the app do when it reconnects and finds the queue was full? That is a product question that should be answered intentionally, not papered over by a queue that will accept whatever the server throws at it. Dropping old events, requesting a full state sync, or showing a "you may have missed updates" notice are all valid answers. An unbounded queue just delays that question until it becomes a production incident.
The Poll API Makes Statelessness Explicit
When you expose a poll endpoint instead of a persistent server-side socket, statelessness stops being an aspiration and becomes a structural property. Each poll call is independent. The client tracks what it has seen. The server does not care about client identity beyond authentication.
This has practical benefits. Reconnect logic with jitter lives naturally on the client:
func scheduleReconnect(attempt: Int) {
let base = min(30.0, pow(2.0, Double(attempt)))
let jitter = Double.random(in: 0..<base * 0.3)
let delay = base + jitter
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.connect()
}
}
Credential refresh on timeout is equally straightforward. The poll call returns, the token is close to expiry, you refresh before the next poll. No persistent connection to re-authenticate mid-stream. No server-side session to invalidate and rebuild.
Timeouts also become lifecycle ticks rather than errors. A poll that returns with no events after 30 seconds is not a failure state. It is an opportunity to check app state, refresh credentials, or update UI. Treating it as an error and alerting leads to noise. Treating it as a scheduled callback leads to clean code.
What the Backend Actually Does
In this model the backend is responsible for:
- Accepting messages from services or webhooks and writing them to a per-user queue
- Authenticating poll requests and returning whatever is in the queue up to the bounded limit
- Expiring old queue entries based on age or position
That is it. No socket framing. No connection tracking. No need for async extensions or long-running workers. A standard PHP controller, a Go handler, a Rails action -- any of them can implement this cleanly.
The native runtime handles connection persistence, reconnection backoff, and queue draining. The backend handles authentication and event delivery. The division is clean because the ownership is clear.
The Concrete Takeaway
Persistent connections are hard to own in request-scoped backends, and most of the complexity developers add is just compensating for that mismatch. Moving connection ownership to the native layer does not eliminate complexity -- it relocates it to a place that is better equipped to handle it. The device stays connected across backend restarts, the backend stays stateless, and the failure modes become explicit rather than emergent. That is a trade worth making.
Top comments (0)