System designDeep dive10 min
Presence & push notifications
Presence answers "who is here right now" for people inside the app; push notifications reach people who aren't. Presence is a guess built from heartbeats and timeouts, and its cost is telling everyone who cares. Push goes through Apple's and Google's services, which deliver on a best-effort basis and drop or merge what they must.
1 · Two halves
Presence serves people in the app; push reaches people who left it
A turn-based word game has 3 million daily players. The friends list shows a green dot for friends who are online, so players can start a quick match, and when someone plays a word, the opponent should hear about it: instantly in the app if it is open, by notification if it isn't. That is two different problems with two different channels:
If Ben has the app open, the move arrives over Ben's live connection in milliseconds. If not, it becomes a notification through the phone platform's push service, which may arrive in seconds, later, or not at all.
The live connection itself is a WebSocket or similar long-lived channel (WebSockets & long polling). This page is about what sits on top: deciding who counts as online, telling the right people when that changes, and handing everything else to push services that you don't control.
2 · Presence
Online means "heard from recently", not "has a connection"
The obvious rule, "online while the socket is open", fails constantly. A phone that enters a tunnel or loses Wi-Fi doesn't close its socket; it simply stops talking, and the server may not notice for minutes. So presence works like any failure detector (Failure detection & split brain): the app sends a small heartbeat every so often, and the server marks it offline after a timeout without one:
Each heartbeat renews an entry that expires on its own. No message is needed to go offline, which is exactly the case the app can't announce.
The numbers are a trade. A short timeout notices departures quickly but flickers a friend's dot every time a train passes under a bridge; a long one shows ghosts who left minutes ago. A short grace period helps: an offline that is followed by a reconnect within a few seconds is never broadcast. The app also says goodbye explicitly when it can, such as when the user closes it, so the timeout only covers the silent cases.
Players use several devices, so presence is per connection, rolled up per person:
- Each connection (phone, tablet, browser tab) has its own entry.
- A person is online while any of their connections is.
- "Last seen" is written when the last connection goes, with the server's clock, not the phone's.
Firebase and Phoenix Presence both model it this way: a list of connections under one user.
3 · The cost
Telling everyone who cares is the expensive part
Storing presence is cheap; broadcasting changes isn't. At the evening peak 600,000 players are online, and each changes state (opens, backgrounds or closes the app) about once every 10 minutes: 600,000 ÷ 600 s = 1,000 changes a second. Each player has about 50 friends:
Who gets told | Messages a second | Why |
|---|---|---|
All 50 friends | 1,000 × 50 = 50,000 | Most of them are offline and can't see it |
Only friends online now (20% at peak) | 1,000 × 10 = 10,000 | Offline friends read presence when they open the app |
Only friends looking at the friends list | A small fraction of that | Subscribe to presence only while it is on screen |
Groups make it quadratic. The game runs weekend tournaments with a lobby that shows everyone who is online. With N players in a lobby, each changing once every 10 minutes, and every change sent to the other N − 1, the lobby generates N × (N − 1) ÷ 600 messages a second:
500 players: 416 messages a second. 5,000 players: 41,658. Ten times the members means a hundred times the traffic.
The fixes all reduce who is told, or how often:
- Counts instead of lists for large groups ("2,341 online"), with the full list fetched on demand.
- Batch and debounce: send one update a few seconds later covering every change in between.
- Subscribe by screen: a client gets presence only for the people it is currently showing.
- Typing indicators and other short-lived signals are never stored and may be dropped when late; they are worth something for a few seconds only.
4 · Across servers
Presence lives across many gateways, and a crashed gateway is a mass logout
600,000 open connections don't fit on one server, so they are spread over a fleet of gateway servers, say 50,000 sockets each (WebSockets & long polling). Ana's gateway must reach Ben's, wherever Ben connected. The usual shape:
- Presence store: a shared, in-memory store of connection entries that expire unless refreshed.
- Pub/sub between gateways: each gateway subscribes to the channels its connected users care about, such as a player's friends or a lobby, and publishes the changes it sees (Message queues & pub/sub).
- Clients reconcile on reconnect: after any gap, the app fetches current presence for what it shows, instead of trusting the updates it might have missed.
Ana and Ben are on different gateways. The change travels through pub/sub; the store holds the current answer for anyone who reconnects.
The dangerous moment is a gateway crash. Its 50,000 connections vanish at once, all their entries expire together, and the system announces 50,000 departures, then 50,000 arrivals a few seconds later when the apps reconnect to other gateways. Grace periods absorb most of that. So does accepting that presence is eventually consistent by design: it is allowed to be a few seconds wrong, never allowed to be expensive to keep right.
Some frameworks avoid a central store altogether. Phoenix Presence replicates presence between the servers of a cluster without a single source of truth, and sends clients only diffs of joins and leaves. Either way, presence is a cache of a guess, so keep it out of anything that must be exact: matchmaking still asks the game service whether a player accepted, not whether their dot is green.
5 · Push
Push notifications go through the platform's service, under its rules
When Ben's app isn't running, the game can't reach Ben's phone directly. Only the platform can: Apple Push Notification service (APNs) for iOS, Firebase Cloud Messaging (FCM) for Android, and a browser's push service for the web (the Web Push protocol). Each phone app registers with its platform and receives a device token, which it sends to the game's backend. The backend sends notifications to that token through the platform:
"200 accepted" means APNs took the notification, not that the phone showed it. Delivery happens later, if the phone is reachable before the notification expires.
The platforms share the same knobs under different names:
Knob | APNs | FCM | Web Push |
|---|---|---|---|
Payload limit | 4 KB (5 KB for VoIP) | 4,096 bytes | At least 4,096 bytes must be accepted |
Urgency | Priority 10 (now), 5 (power-aware), 1 | High or normal | Urgency: very-low to high |
Replace an older one | apns-collapse-id | Collapse key (up to 4 stored per app) | Topic |
Give up after | apns-expiration (0 = now or never) | Time to live, up to 28 days | TTL (required) |
Tokens need care too. A token stops working when the app is uninstalled or the platform rotates it, so store each token with the time it was last refreshed, delete it the moment the platform says it is invalid (FCM returns UNREGISTERED with HTTP 404), and treat tokens that haven't connected for a month as stale, as FCM itself does.
6 · In practice
Push is a best-effort hint: design for loss, merging and quiet
Push services make no promise that every notification arrives, and they say so in their rules:
- Offline phones keep only a little. APNs stores only the most recent notification for an app while the device is offline; FCM keeps the latest per collapse key, and on Android discards all stored non-collapsible messages if more than 100 pile up.
- Expiry. A notification whose lifetime ends before the phone is reachable is dropped.
- Power saving. Android's Doze suspends network access between maintenance windows; high-priority messages can wake the app, but high-priority messages that don't show the user anything may be deprioritized.
So a push is a hint, never the data. "Ana played QUIZ" is a notification; the move itself lives in the game service, and the app fetches the current game state whenever it opens, whatever notifications did or didn't arrive. Use the collapse ID per game, so ten moves while Ben is offline become one notification about game 77, not ten.
- Choose the channel by presence
Online and in the game: send over the socket and skip the push. Offline: push.
- Send from a queue
A notifier service reads a queue, so a slow or failing platform never blocks the game, and retries are mostly harmless: a collapse ID (on Android, a tag) makes a repeat replace the earlier notification, though a retry after delivery can still alert again.
- Respect the person
Per-user preferences, quiet hours and a cap per day. A player who mutes the app loses you more than one missed notification.
- Keep tokens clean
Refresh on app start, delete on invalid responses, and expire stale ones.
- Measure delivery
Track sent, accepted and opened; the gap shows what the platforms dropped.
Check yourself
4 questions
Takeaways
Remember this
- Presence is a guess from heartbeats: online means heard from recently, with a timeout for silent departures and a grace period against flicker.
- Track presence per connection and roll it up per person; store last seen with the server's clock.
- Broadcasting changes is the cost: tell only online, interested viewers, batch updates, and show counts for large groups, where traffic grows with the square of the size.
- Push goes through APNs, FCM or Web Push with priority, collapse and expiry settings; acceptance is not delivery.
- Treat a push as a hint: the app syncs real state on open, collapse IDs merge repeats, and presence decides socket or push.