How GitHub shows live collaborators editing a PR
How GitHub broadcasts cursor positions and edit presence to collaborators using WebSocket channels, distributed pub-sub, and client-side reconciliation.
The Problem Statement
Interviewer: "You open a pull request on GitHub and you see little avatars showing who else is currently viewing the same file. You can even see another person's cursor blinking in the diff. How does GitHub build this real-time presence system? Walk me through the architecture."
This question tests three things: your understanding of WebSocket-based real-time communication, your ability to reason about distributed pub-sub for fan-out across server instances, and whether you can handle the subtle timing problems in presence systems (stale users, network partitions, reconnection storms).
I like this question because it starts simple ("just show who is online") but the difficulty scales fast once you consider multiple server instances, graceful timeout handling, and cursor position interpolation across unreliable networks. Most candidates get a decent answer for a single-server version but struggle when scaling to hundreds of thousands of concurrent PR viewers.
Clarifying the Scenario
You: "Before I start, let me scope this so I am solving the right problem."
You: "When you say 'presence,' are you talking about just showing who is looking at this PR right now (like Google Docs showing viewer avatars), or actual real-time cursor and selection broadcasting?"
Interviewer: "Both. Start with basic presence, then show how you would layer cursor broadcasting on top."
You: "Got it. Should I assume a single data center, or multi-region?"
Interviewer: "Start single-region, mention what changes for multi-region."
You: "And what scale should I plan for? Some PRs in popular open-source repos can have hundreds of simultaneous viewers."
Interviewer: "Yes, plan for up to 500 concurrent viewers per PR, with millions of PRs active at any time."
You: "OK. I will structure my answer in three parts: the core presence system that tracks who is online, the WebSocket fan-out architecture that scales across multiple server instances, and the client-side cursor interpolation that makes remote cursors feel smooth."
My Approach
I break this into four parts:
- Presence tracking: Each viewer opens a WebSocket connection, sends periodic heartbeats, and the server maintains a set of active users per PR. When a heartbeat stops, the user is removed after a timeout.
- Distributed fan-out: With multiple WebSocket server instances, a user connected to Server A needs to see presence updates from users on Server B. This requires a pub-sub layer (Redis Pub/Sub or a message bus) to broadcast presence changes across all instances.
- Cursor broadcasting: On top of presence, sending cursor position updates at 10-30Hz to all viewers. This is much higher volume than presence and requires batching, throttling, and client-side interpolation.
- Edge cases: Stale presence after a browser crash (no clean disconnect), reconnection storms after a deploy, and the "ghost cursor" problem when a user goes idle.
The mental model is this: presence is a set membership problem ("who is in this room right now?"), and cursor broadcasting is a real-time event stream problem ("where is everyone's cursor right now?"). They share the same WebSocket transport but have very different data patterns and scaling characteristics.
GitHub Copilot Workspace and Google Docs use similar architectures for presence. The core pattern is the same: WebSocket for push, distributed pub-sub for fan-out, heartbeat for liveness. The differences are in the details of cursor interpolation and conflict resolution.
The Architecture
Here is the full system. Users connect via WebSocket to one of many stateless server instances. Presence events flow through a Redis Pub/Sub layer so all instances can fan out updates to their local connections.
Here is how the flow works step by step:
- Alice opens PR #4821. Her browser establishes a WebSocket connection to WS Server 1.
- WS Server 1 adds Alice to the Redis hash
pr:4821:viewerswith a 30-second TTL. - WS Server 1 publishes a "user_joined" event to the Redis Pub/Sub channel
pr:4821:presence. - WS Servers 2 and 3 receive this event (they are subscribed to the same channel) and push it to their local WebSocket connections.
- Bob and Carol see Alice's avatar appear in the PR viewer list.
- Every 15 seconds, Alice's browser sends a heartbeat. WS Server 1 refreshes her TTL in Redis.
- When Alice closes the tab, she sends a "leave" message. If her browser crashes, the 30-second TTL expires and WS Server 1 publishes a "user_left" event.
For your interview: the key insight is that the WebSocket tier is stateless. Any server can handle any connection. The Redis Pub/Sub layer is what makes cross-server communication possible. This is the same pattern Slack, Discord, and Figma use.
Here is the message format flowing through the system. Each presence event is a small JSON payload, typically under 200 bytes:
// Presence event (join/leave/idle)
{
"type": "presence",
"action": "joined", // "joined" | "left" | "idle" | "active"
"user": "alice",
"avatar": "https://avatars.github.com/u/12345",
"file": "src/app.ts",
"pr": 4821,
"timestamp": 1713100800
}
// Cursor event (high-frequency, 10Hz)
{
"type": "cursor",
"user": "alice",
"file": "src/app.ts",
"line": 42,
"column": 15,
"diffSha": "a1b2c3d", // diff version for remapping
"timestamp": 1713100800123
}
The presence events are low-frequency (once on join, once on leave, once on idle transition). Cursor events are high-frequency (10 per second per active user). This difference drives the entire scaling strategy: you handle them differently inside the same WebSocket connection.
The strongest answer here separates the two event types clearly. Presence events are stored (Redis hash with TTL). Cursor events are ephemeral (pub-sub only, never persisted). This distinction simplifies the storage layer and reduces write load on Redis.
The Presence Heartbeat and Timeout System
The hardest part of presence is not knowing when someone leaves. A clean tab close sends a beforeunload event, but a browser crash, network drop, or mobile app kill does not. You need a heartbeat system to detect stale presence.
The heartbeat interval and timeout values matter. Here is the tradeoff:
| Parameter | Too Low | Too High | Sweet Spot |
|---|---|---|---|
| Heartbeat interval | Excessive bandwidth, server load | Slow stale detection | 10-20 seconds |
| TTL timeout | False "offline" during network blips | Ghost users linger for minutes | 2-3x heartbeat interval |
| Cursor update rate | Thousands of messages/sec | Laggy cursors | 10-15Hz, throttled |
A common mistake is using Redis key expiry notifications (__keyevent@0__:expired) as the sole timeout mechanism. Redis keyspace notifications are not guaranteed to be delivered if the Redis instance is under heavy load or if no subscriber is listening at the moment of expiry. Always combine TTL-based expiry with periodic cleanup sweeps.
WebSocket Fan-Out Across Server Instances
The single hardest scaling problem is this: Alice is connected to WS Server 1 and Bob is connected to WS Server 2. When Alice moves her cursor, Bob needs to see it. The WebSocket servers need a way to communicate.
The channel design matters for performance. Here are three approaches:
The rule of thumb: one Redis Pub/Sub instance handles about 100K messages per second comfortably. Beyond that, shard. For presence-only events (low frequency, small payload), this threshold is rarely hit. For cursor broadcasting (10Hz per user, larger payloads), you hit it faster than you expect.
Client-Side Cursor Interpolation and Conflict
When Alice moves her cursor from line 42 to line 50, the server broadcasts this position at 10Hz. Bob's client receives updates every 100ms. Without interpolation, Alice's cursor on Bob's screen jumps in discrete steps. With interpolation, it moves smoothly.
The cursor interpolation problem has a subtlety most candidates miss. When the PR diff changes (someone pushes a new commit while you are viewing), all cursor line numbers may shift. Line 42 in the old diff might be line 45 in the new diff. The client needs to either:
- Instantly reposition all cursors based on the new diff (jarring but accurate)
- Fade out remote cursors during a diff update and let them reappear at new positions
- Map old line numbers to new line numbers using the diff hunks
The strong answer here is to mention operational transform (OT) or CRDT-based line mapping. Google Docs uses OT, and newer systems like Figma use CRDTs. For GitHub's PR viewer, the problem is simpler because the diff is read-only (you are not editing it), so a simple line-number remapping based on diff hunks is sufficient.
The Tricky Parts
-
Reconnection storms after deploys: When GitHub deploys a new WebSocket server version, all connections to that server drop simultaneously. If 10,000 users reconnect at the same time, the presence store gets flooded with join events. The solution is jittered reconnection: the client waits a random 0-5 seconds before reconnecting, and the server accepts connections with a rate limiter.
-
Presence across browser tabs: If Alice has the same PR open in three tabs, she should appear as one viewer, not three. The server deduplicates by user ID, not connection ID. But if one tab is on file A and another is on file B, which cursor position do you show? The answer: the most recently active tab wins.
-
The "idle" state: If a user opens a PR and walks away for 30 minutes, their heartbeat keeps running (the tab is still open). They show as "viewing" even though they are not really present. The solution is an activity timer: if no mouse/keyboard/scroll activity for 5 minutes, the client sends an "idle" state instead of "active." The avatar grays out.
-
Enterprise-grade privacy: Some organizations do not want other users to see who is viewing a PR (competitive intelligence, security reviews). The presence system needs a per-org or per-repo privacy toggle that suppresses presence broadcasting entirely while still maintaining the WebSocket connection for other features.
-
Rate limiting cursor broadcasts: A user rapidly selecting and deselecting text (or using vim-style cursor jumps) can generate hundreds of cursor events per second. The client must locally throttle to 10-15 events per second before sending. The server should also enforce a per-connection rate limit as a safety net.
-
Memory pressure on the WebSocket tier: Each WebSocket connection consumes memory on the server (socket buffers, connection metadata, subscription tracking). A single WS server handling 10K connections to popular PRs, each subscribed to 1-3 pub-sub channels, needs careful memory budgeting. The rule of thumb: plan for 50-100KB per connection including buffers, which means a server with 8GB of RAM handles roughly 80K-160K connections.
-
Ordering of presence events: Redis Pub/Sub does not guarantee cross-channel ordering, and network latency between the pub-sub layer and different WS servers varies. Alice might see Bob join before Carol, while Dave sees Carol join before Bob. For presence (avatar list), this is cosmetic and acceptable. For cursor positions, out-of-order updates should be handled with timestamp-based last-writer-wins: discard any cursor update with a timestamp older than the last rendered position.
What Most People Get Wrong
| Mistake | What they say | Why it is wrong | What to say instead |
|---|---|---|---|
| Ignoring multi-server | "The WebSocket server broadcasts to all connections" | This only works with one server. At scale you have dozens. | "I would use Redis Pub/Sub (or NATS) to fan out events across all WebSocket server instances." |
| No timeout for crashes | "When the WebSocket closes, remove the user" | A browser crash does not fire the close event. The user appears online forever. | "Heartbeat every 15s with 30s TTL. If the heartbeat stops, the TTL expires and the user is removed." |
| Polling for presence | "Each client polls every few seconds for the viewer list" | At 500 viewers, that is 100 requests/second per PR just for presence. | "Server pushes presence changes over WebSocket. Clients only receive diffs, not the full list." |
| Sending raw coordinates | "Send the cursor's x,y pixel position" | Different screen sizes, zoom levels, and scroll positions make pixel coordinates meaningless. | "Send logical positions (file, line, column) and let each client render in their own viewport." |
| One global channel | "All presence events go through one Redis channel" | A single channel becomes a bottleneck at thousands of active PRs. | "One channel per PR. Subscribe/unsubscribe as connections join and leave." |
How I Would Communicate This in an Interview
Here is how I would actually say this:
"The presence system has three layers. First, each user viewing a PR opens a WebSocket connection to one of our stateless server instances. The server adds the user to a Redis hash keyed by PR ID with a 30-second TTL, and publishes a 'joined' event to a Redis Pub/Sub channel for that PR.
Second, because users are connected to different server instances, I use Redis Pub/Sub as the cross-server fan-out layer. Each WebSocket server subscribes to channels for PRs that have local connections. When a presence event is published, all subscribing servers push it to their relevant connections.
Third, for cursor broadcasting, I layer cursor position updates on top of the same WebSocket connection. The client throttles updates to 10Hz. Positions are sent as logical coordinates (file, line, column), not pixel positions, so they work across different screen sizes. The receiving client interpolates cursor movement at 60fps for visual smoothness.
The tricky part is handling disconnects without a clean close event. The heartbeat runs every 15 seconds. If two heartbeats are missed, the 30-second TTL expires and the user is removed. I add a 10-second grace window to avoid flickering during brief network blips.
At extreme scale, a single viral PR with thousands of viewers, I would shard the Redis Pub/Sub across multiple instances and add hierarchical fan-out relays. But for 99% of PRs where there are fewer than 50 concurrent viewers, the basic architecture handles it."
Interview Cheat Sheet
- Trigger: "real-time presence" or "show who is online" β Say: "WebSocket connections with server-side heartbeat tracking and distributed pub-sub for cross-instance fan-out."
- Trigger: "how do you know when someone leaves?" β Say: "Heartbeat every 15 seconds, TTL of 30 seconds on the presence entry. No heartbeat means TTL expires and the user is removed."
- Trigger: "what about multiple servers?" β Say: "Redis Pub/Sub with one channel per room (PR, document, etc.). Each server subscribes to channels for its local connections."
- Trigger: "cursor positions" β Say: "Logical coordinates (file, line, column), not pixel positions. Throttled to 10Hz on the client, interpolated to 60fps on the receiver."
- Trigger: "scale to thousands of viewers" β Say: "Shard Redis Pub/Sub, add hierarchical relay servers for fan-out, batch cursor updates."
- Trigger: "browser crash" β Say: "No clean disconnect event. The heartbeat-based TTL handles it. User disappears within 30-40 seconds."
- Trigger: "reconnection storms" β Say: "Jittered exponential backoff on the client. Server-side rate limiting on connection acceptance."
- Trigger: "privacy concerns" β Say: "Per-org or per-repo toggle to suppress presence broadcasting. The WebSocket stays open for other features."
- Trigger: "idle users" β Say: "Client-side activity timer. After 5 minutes of no interaction, send an 'idle' state. The avatar grays out but the user is not removed."
- Trigger: "diff changes while viewing" β Say: "Cursor positions include the diff commit SHA. When the diff updates, the client remaps line numbers using diff hunks."
Test Your Understanding
Quick Recap
- Presence is a set membership problem: track who is currently in a "room" (PR, document, channel) and broadcast changes to all other members.
- WebSocket connections provide the push channel. Heartbeats with TTL-based expiry handle crash detection without relying on clean disconnect events.
- Distributed pub-sub (Redis Pub/Sub, NATS) solves the cross-server fan-out problem so users on different WebSocket instances see each other.
- One channel per room keeps the message volume proportional to room activity, not global activity.
- Cursor positions must be sent as logical coordinates (file, line, column), never pixel coordinates, because viewers have different screens and scroll positions.
- Client-side interpolation smooths cursor movement from 10Hz updates to 60fps rendering, making remote cursors feel natural.
- The grace window (delaying "left" events briefly) prevents avatar flickering during brief network interruptions.
- At extreme scale (thousands of viewers per room), shard the pub-sub layer and introduce hierarchical relay servers for fan-out.
Related Concepts
- WebSocket protocol: The underlying transport for persistent, bidirectional communication between browser and server.
- Distributed pub-sub patterns: Redis Pub/Sub, NATS, Kafka as fan-out mechanisms. Each has different durability and ordering guarantees.
- Operational Transform and CRDTs: Conflict resolution algorithms used by collaborative editors like Google Docs and Figma for text editing (beyond cursor presence).
- Circuit breaker for real-time systems: Protecting the presence store from reconnection storms using backpressure and rate limiting.
- Consistent hashing: Used to shard pub-sub channels across multiple Redis instances while keeping related channels on the same shard.