[{"data":1,"prerenderedAt":1022},["ShallowReactive",2],{"blog:/blog/presence-tracking-at-scale":3},{"id":4,"title":5,"author":6,"body":7,"category":1011,"date":1012,"description":1013,"excerpt":1014,"extension":1015,"meta":1016,"navigation":156,"path":1017,"readTime":1018,"seo":1019,"stem":1020,"__hash__":1021},"blog/blog/presence-tracking-at-scale.md","Building Presence at Scale: Who Is Online and How to Track It","Henco Burger",{"type":8,"value":9,"toc":998},"minimark",[10,14,17,22,25,28,57,60,64,67,233,236,239,242,245,249,252,255,402,405,409,412,418,424,427,431,436,443,447,450,454,461,464,468,471,475,478,497,792,801,811,820,983,994],[11,12,13],"p",{},"Presence is one of those features that looks trivial until you actually build it. Showing a green dot next to a username seems simple. But the moment you have thousands of users spread across hundreds of rooms, connecting and disconnecting on flaky mobile networks, switching tabs, backgrounding the app, and coming back after hours offline, it stops being simple very quickly.",[11,15,16],{},"This post covers what presence actually means, why the naive approaches fall apart, and how to build something that holds up at scale.",[18,19,21],"h2",{"id":20},"what-presence-actually-means","What Presence Actually Means",[11,23,24],{},"Presence is the real-time state of a user or device as observed by others. The most basic form is binary: online or offline. Most production systems need more than that.",[11,26,27],{},"A richer presence model typically includes:",[29,30,31,39,45,51],"ul",{},[32,33,34,38],"li",{},[35,36,37],"strong",{},"Status."," Online, offline, away, busy, do not disturb.",[32,40,41,44],{},[35,42,43],{},"Last seen."," A timestamp of the most recent activity or heartbeat.",[32,46,47,50],{},[35,48,49],{},"Custom metadata."," The current room a user is in, what they are viewing, their display name, avatar URL, or anything your application needs to show other users.",[32,52,53,56],{},[35,54,55],{},"Device context."," Whether the user is on mobile or desktop, which can affect what status transitions mean.",[11,58,59],{},"Custom metadata is where presence gets genuinely useful. A collaborative document editor wants to show which paragraph each user is editing. A support tool wants to show which ticket an agent has open. A multiplayer game wants to show the player's current lobby and score. All of that is presence data, not just a green dot.",[18,61,63],{"id":62},"the-naive-approach-polling","The Naive Approach: Polling",[11,65,66],{},"The most common first attempt at presence is a REST endpoint that clients call periodically to report that they are still alive, and a second endpoint that other clients poll to fetch the current status of users they care about.",[68,69,70],"code-tabs",{},[71,72,78],"pre",{"className":73,"code":74,"filename":75,"language":76,"meta":77,"style":77},"language-ts shiki shiki-themes github-light github-dark","// Client heartbeat poll every 30 seconds\nsetInterval(async () => {\n  await fetch('/api/presence/heartbeat', { method: 'POST' })\n}, 30_000)\n\n// Other clients poll for status\nsetInterval(async () => {\n  const res = await fetch('/api/presence?userIds=u1,u2,u3')\n  updatePresenceUI(await res.json())\n}, 5_000)\n","TypeScript","ts","",[79,80,81,90,114,138,151,158,164,179,203,223],"code",{"__ignoreMap":77},[82,83,86],"span",{"class":84,"line":85},"line",1,[82,87,89],{"class":88},"sJ8bj","// Client heartbeat poll every 30 seconds\n",[82,91,93,97,101,105,108,111],{"class":84,"line":92},2,[82,94,96],{"class":95},"sScJk","setInterval",[82,98,100],{"class":99},"sVt8B","(",[82,102,104],{"class":103},"szBVR","async",[82,106,107],{"class":99}," () ",[82,109,110],{"class":103},"=>",[82,112,113],{"class":99}," {\n",[82,115,117,120,123,125,129,132,135],{"class":84,"line":116},3,[82,118,119],{"class":103},"  await",[82,121,122],{"class":95}," fetch",[82,124,100],{"class":99},[82,126,128],{"class":127},"sZZnC","'/api/presence/heartbeat'",[82,130,131],{"class":99},", { method: ",[82,133,134],{"class":127},"'POST'",[82,136,137],{"class":99}," })\n",[82,139,141,144,148],{"class":84,"line":140},4,[82,142,143],{"class":99},"}, ",[82,145,147],{"class":146},"sj4cs","30_000",[82,149,150],{"class":99},")\n",[82,152,154],{"class":84,"line":153},5,[82,155,157],{"emptyLinePlaceholder":156},true,"\n",[82,159,161],{"class":84,"line":160},6,[82,162,163],{"class":88},"// Other clients poll for status\n",[82,165,167,169,171,173,175,177],{"class":84,"line":166},7,[82,168,96],{"class":95},[82,170,100],{"class":99},[82,172,104],{"class":103},[82,174,107],{"class":99},[82,176,110],{"class":103},[82,178,113],{"class":99},[82,180,182,185,188,191,194,196,198,201],{"class":84,"line":181},8,[82,183,184],{"class":103},"  const",[82,186,187],{"class":146}," res",[82,189,190],{"class":103}," =",[82,192,193],{"class":103}," await",[82,195,122],{"class":95},[82,197,100],{"class":99},[82,199,200],{"class":127},"'/api/presence?userIds=u1,u2,u3'",[82,202,150],{"class":99},[82,204,206,209,211,214,217,220],{"class":84,"line":205},9,[82,207,208],{"class":95},"  updatePresenceUI",[82,210,100],{"class":99},[82,212,213],{"class":103},"await",[82,215,216],{"class":99}," res.",[82,218,219],{"class":95},"json",[82,221,222],{"class":99},"())\n",[82,224,226,228,231],{"class":84,"line":225},10,[82,227,143],{"class":99},[82,229,230],{"class":146},"5_000",[82,232,150],{"class":99},[11,234,235],{},"This works for a demo. It falls apart in production for several reasons.",[11,237,238],{},"First, the polling interval is a compromise you can never win. Poll every 5 seconds and you hammer your API with requests. Poll every 30 seconds and the UI feels stale. A user can log out and their presence stays green for half a minute.",[11,240,241],{},"Second, the load scales badly. With 1,000 users each polling every 5 seconds, you have 200 requests per second just to maintain a feature that should be passive. Add more users and you add proportionally more load, with zero actual information being delivered most of the time, since status has not changed.",[11,243,244],{},"Third, ungraceful disconnects are invisible. If a user closes their laptop, the heartbeat stops. Your server only knows they are gone after the heartbeat timeout expires, which you set at 60 seconds to avoid false negatives. So users stay \"online\" for a minute after they have left.",[18,246,248],{"id":247},"the-event-driven-approach","The Event-Driven Approach",[11,250,251],{},"A better model flips the direction. Instead of clients asking \"who is online?\", the server pushes presence changes to subscribers as they happen. This is the pub/sub model applied to presence.",[11,253,254],{},"When a user connects, the server emits a join event to the relevant channel. When they disconnect, it emits a leave event. Subscribers receive these events in real time and update their local state accordingly.",[68,256,257],{},[71,258,260],{"className":73,"code":259,"filename":75,"language":76,"meta":77,"style":77},"// Server emits on connection\npresence.emit('join', {\n  userId: 'u_abc123',\n  status: 'online',\n  metadata: { displayName: 'Alice', avatar: 'https://...' },\n  roomId: 'room_xyz',\n  timestamp: Date.now(),\n})\n\n// Server emits on disconnect\npresence.emit('leave', {\n  userId: 'u_abc123',\n  roomId: 'room_xyz',\n  timestamp: Date.now(),\n})\n",[79,261,262,267,283,294,304,321,331,342,347,351,356,370,379,388,397],{"__ignoreMap":77},[82,263,264],{"class":84,"line":85},[82,265,266],{"class":88},"// Server emits on connection\n",[82,268,269,272,275,277,280],{"class":84,"line":92},[82,270,271],{"class":99},"presence.",[82,273,274],{"class":95},"emit",[82,276,100],{"class":99},[82,278,279],{"class":127},"'join'",[82,281,282],{"class":99},", {\n",[82,284,285,288,291],{"class":84,"line":116},[82,286,287],{"class":99},"  userId: ",[82,289,290],{"class":127},"'u_abc123'",[82,292,293],{"class":99},",\n",[82,295,296,299,302],{"class":84,"line":140},[82,297,298],{"class":99},"  status: ",[82,300,301],{"class":127},"'online'",[82,303,293],{"class":99},[82,305,306,309,312,315,318],{"class":84,"line":153},[82,307,308],{"class":99},"  metadata: { displayName: ",[82,310,311],{"class":127},"'Alice'",[82,313,314],{"class":99},", avatar: ",[82,316,317],{"class":127},"'https://...'",[82,319,320],{"class":99}," },\n",[82,322,323,326,329],{"class":84,"line":160},[82,324,325],{"class":99},"  roomId: ",[82,327,328],{"class":127},"'room_xyz'",[82,330,293],{"class":99},[82,332,333,336,339],{"class":84,"line":166},[82,334,335],{"class":99},"  timestamp: Date.",[82,337,338],{"class":95},"now",[82,340,341],{"class":99},"(),\n",[82,343,344],{"class":84,"line":181},[82,345,346],{"class":99},"})\n",[82,348,349],{"class":84,"line":205},[82,350,157],{"emptyLinePlaceholder":156},[82,352,353],{"class":84,"line":225},[82,354,355],{"class":88},"// Server emits on disconnect\n",[82,357,359,361,363,365,368],{"class":84,"line":358},11,[82,360,271],{"class":99},[82,362,274],{"class":95},[82,364,100],{"class":99},[82,366,367],{"class":127},"'leave'",[82,369,282],{"class":99},[82,371,373,375,377],{"class":84,"line":372},12,[82,374,287],{"class":99},[82,376,290],{"class":127},[82,378,293],{"class":99},[82,380,382,384,386],{"class":84,"line":381},13,[82,383,325],{"class":99},[82,385,328],{"class":127},[82,387,293],{"class":99},[82,389,391,393,395],{"class":84,"line":390},14,[82,392,335],{"class":99},[82,394,338],{"class":95},[82,396,341],{"class":99},[82,398,400],{"class":84,"line":399},15,[82,401,346],{"class":99},[11,403,404],{},"Clients subscribe to the presence channel for the rooms they care about. No polling, no wasted requests, no artificial delay. Updates arrive within milliseconds of the actual state change.",[18,406,408],{"id":407},"per-room-vs-global-presence","Per-Room vs Global Presence",[11,410,411],{},"There are two presence scopes you will need to think about separately.",[11,413,414,417],{},[35,415,416],{},"Per-room presence"," tracks who is in a specific room right now. This is the right model for chat rooms, collaborative documents, video calls, or any context where membership is bounded. A user joining room A does not need to know about users in rooms B through Z.",[11,419,420,423],{},[35,421,422],{},"Global presence"," (sometimes called a lobby) tracks who is online across the entire application, regardless of which room they are in. This is useful for friend lists, direct message availability indicators, or dashboards where you want to see all connected users.",[11,425,426],{},"Most applications need both. A chat product might show global presence on a contacts sidebar while showing per-room presence inside a conversation. The architecture handles them differently. Per-room presence is scoped to the room's pub/sub channel. Global presence runs on a separate shared channel that all authenticated connections subscribe to on connect and unsubscribe from on disconnect.",[18,428,430],{"id":429},"edge-cases-that-will-bite-you","Edge Cases That Will Bite You",[432,433,435],"h3",{"id":434},"tab-switching","Tab Switching",[11,437,438,439,442],{},"When a user switches to another browser tab, the Page Visibility API fires a ",[79,440,441],{},"visibilitychange"," event. Most presence systems interpret this as \"away\" after a short delay. The trick is debouncing: a user switching tabs to check Slack and switching back within 10 seconds should not flip to away and back. Set a threshold, typically 30 to 60 seconds of hidden state, before emitting an away transition.",[432,444,446],{"id":445},"mobile-backgrounding","Mobile Backgrounding",[11,448,449],{},"On mobile, backgrounding an app is more aggressive than tab switching. The OS can suspend the JavaScript thread entirely, which means the WebSocket connection stops sending keepalives. The server will see the connection as dead after the heartbeat timeout. When the user foregrounds the app, you need to detect that the socket was dropped and reconnect, then re-emit a join event with updated metadata.",[432,451,453],{"id":452},"ungraceful-disconnects","Ungraceful Disconnects",[11,455,456,457,460],{},"A graceful disconnect happens when the client calls ",[79,458,459],{},"socket.close()"," and the server receives the close frame. An ungraceful disconnect happens when the network drops, the process is killed, or the device loses power. In the ungraceful case, the server will not know the client is gone until the heartbeat timeout fires.",[11,462,463],{},"The standard solution is a server-side heartbeat: the server expects a ping from each client on a regular interval, typically every 20 to 30 seconds. If no ping arrives within the window, the server marks the client as disconnected and emits a leave event. Clients that are truly connected will send their ping and stay online. Clients that have silently dropped will be cleaned up within one heartbeat window.",[432,465,467],{"id":466},"multiple-connections","Multiple Connections",[11,469,470],{},"A single user can be connected from multiple tabs or devices simultaneously. A naive implementation will emit a leave event when one tab closes, even though the user is still connected from another tab. The fix is reference counting at the user level. The server tracks how many active connections exist per user ID. It only emits a leave event when the count reaches zero.",[18,472,474],{"id":473},"how-nolag-handles-presence","How NoLag Handles Presence",[11,476,477],{},"NoLag builds presence into the room and lobby primitives so you do not have to wire it yourself. Three things are worth knowing up front, because each one differs from the generic design above.",[11,479,480,483,484,487,488,492,493,496],{},[35,481,482],{},"Membership is explicit."," Subscribing to a room's topics does not put you in its presence list. A client joins by calling ",[79,485,486],{},"room.setPresence(data)",", which is also how it updates its metadata later; the first call is the join event other members see. A connection holds presence in one room at a time, so setting presence in a second room moves it there. And presence ",[489,490,491],"em",{},"events"," are delivered on the client, not the room: ",[79,494,495],{},"room.on()"," is for topic messages.",[68,498,499],{},[71,500,502],{"className":73,"code":501,"filename":75,"language":76,"meta":77,"style":77},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(ACTOR_TOKEN)\n\n// Presence events arrive on the client. Each carries actorTokenId and presence.\nclient.on('presence:join', (actor) => addToRoster(actor.actorTokenId, actor.presence))\nclient.on('presence:update', (actor) => updateRoster(actor.actorTokenId, actor.presence))\nclient.on('presence:leave', (actor) => removeFromRoster(actor.actorTokenId))\n\nawait client.connect()\n\n// APP_SLUG is the slug returned when you created the app (it has a random\n// suffix). The room must already exist: rooms are created in the control plane.\nconst room = client.setApp(APP_SLUG).setRoom('room_xyz')\n\n// This is the join. Include whatever metadata other members should see.\nroom.setPresence({ status: 'online', displayName: 'Alice', avatar: 'https://...' })\n\n// Bootstrap the roster with the current members\nconst members = await room.fetchPresence()\n\n// Update at any time; the others receive presence:update\nroom.setPresence({ status: 'away', displayName: 'Alice', avatar: 'https://...' })\n",[79,503,504,518,522,542,546,551,582,606,631,635,648,652,657,662,693,697,703,728,733,739,759,764,770],{"__ignoreMap":77},[82,505,506,509,512,515],{"class":84,"line":85},[82,507,508],{"class":103},"import",[82,510,511],{"class":99}," { NoLag } ",[82,513,514],{"class":103},"from",[82,516,517],{"class":127}," '@nolag/js-sdk'\n",[82,519,520],{"class":84,"line":92},[82,521,157],{"emptyLinePlaceholder":156},[82,523,524,527,530,532,535,537,540],{"class":84,"line":116},[82,525,526],{"class":103},"const",[82,528,529],{"class":146}," client",[82,531,190],{"class":103},[82,533,534],{"class":95}," NoLag",[82,536,100],{"class":99},[82,538,539],{"class":146},"ACTOR_TOKEN",[82,541,150],{"class":99},[82,543,544],{"class":84,"line":140},[82,545,157],{"emptyLinePlaceholder":156},[82,547,548],{"class":84,"line":153},[82,549,550],{"class":88},"// Presence events arrive on the client. Each carries actorTokenId and presence.\n",[82,552,553,556,559,561,564,567,571,574,576,579],{"class":84,"line":160},[82,554,555],{"class":99},"client.",[82,557,558],{"class":95},"on",[82,560,100],{"class":99},[82,562,563],{"class":127},"'presence:join'",[82,565,566],{"class":99},", (",[82,568,570],{"class":569},"s4XuR","actor",[82,572,573],{"class":99},") ",[82,575,110],{"class":103},[82,577,578],{"class":95}," addToRoster",[82,580,581],{"class":99},"(actor.actorTokenId, actor.presence))\n",[82,583,584,586,588,590,593,595,597,599,601,604],{"class":84,"line":166},[82,585,555],{"class":99},[82,587,558],{"class":95},[82,589,100],{"class":99},[82,591,592],{"class":127},"'presence:update'",[82,594,566],{"class":99},[82,596,570],{"class":569},[82,598,573],{"class":99},[82,600,110],{"class":103},[82,602,603],{"class":95}," updateRoster",[82,605,581],{"class":99},[82,607,608,610,612,614,617,619,621,623,625,628],{"class":84,"line":181},[82,609,555],{"class":99},[82,611,558],{"class":95},[82,613,100],{"class":99},[82,615,616],{"class":127},"'presence:leave'",[82,618,566],{"class":99},[82,620,570],{"class":569},[82,622,573],{"class":99},[82,624,110],{"class":103},[82,626,627],{"class":95}," removeFromRoster",[82,629,630],{"class":99},"(actor.actorTokenId))\n",[82,632,633],{"class":84,"line":205},[82,634,157],{"emptyLinePlaceholder":156},[82,636,637,639,642,645],{"class":84,"line":225},[82,638,213],{"class":103},[82,640,641],{"class":99}," client.",[82,643,644],{"class":95},"connect",[82,646,647],{"class":99},"()\n",[82,649,650],{"class":84,"line":358},[82,651,157],{"emptyLinePlaceholder":156},[82,653,654],{"class":84,"line":372},[82,655,656],{"class":88},"// APP_SLUG is the slug returned when you created the app (it has a random\n",[82,658,659],{"class":84,"line":381},[82,660,661],{"class":88},"// suffix). The room must already exist: rooms are created in the control plane.\n",[82,663,664,666,669,671,673,676,678,681,684,687,689,691],{"class":84,"line":390},[82,665,526],{"class":103},[82,667,668],{"class":146}," room",[82,670,190],{"class":103},[82,672,641],{"class":99},[82,674,675],{"class":95},"setApp",[82,677,100],{"class":99},[82,679,680],{"class":146},"APP_SLUG",[82,682,683],{"class":99},").",[82,685,686],{"class":95},"setRoom",[82,688,100],{"class":99},[82,690,328],{"class":127},[82,692,150],{"class":99},[82,694,695],{"class":84,"line":399},[82,696,157],{"emptyLinePlaceholder":156},[82,698,700],{"class":84,"line":699},16,[82,701,702],{"class":88},"// This is the join. Include whatever metadata other members should see.\n",[82,704,706,709,712,715,717,720,722,724,726],{"class":84,"line":705},17,[82,707,708],{"class":99},"room.",[82,710,711],{"class":95},"setPresence",[82,713,714],{"class":99},"({ status: ",[82,716,301],{"class":127},[82,718,719],{"class":99},", displayName: ",[82,721,311],{"class":127},[82,723,314],{"class":99},[82,725,317],{"class":127},[82,727,137],{"class":99},[82,729,731],{"class":84,"line":730},18,[82,732,157],{"emptyLinePlaceholder":156},[82,734,736],{"class":84,"line":735},19,[82,737,738],{"class":88},"// Bootstrap the roster with the current members\n",[82,740,742,744,747,749,751,754,757],{"class":84,"line":741},20,[82,743,526],{"class":103},[82,745,746],{"class":146}," members",[82,748,190],{"class":103},[82,750,193],{"class":103},[82,752,753],{"class":99}," room.",[82,755,756],{"class":95},"fetchPresence",[82,758,647],{"class":99},[82,760,762],{"class":84,"line":761},21,[82,763,157],{"emptyLinePlaceholder":156},[82,765,767],{"class":84,"line":766},22,[82,768,769],{"class":88},"// Update at any time; the others receive presence:update\n",[82,771,773,775,777,779,782,784,786,788,790],{"class":84,"line":772},23,[82,774,708],{"class":99},[82,776,711],{"class":95},[82,778,714],{"class":99},[82,780,781],{"class":127},"'away'",[82,783,719],{"class":99},[82,785,311],{"class":127},[82,787,314],{"class":99},[82,789,317],{"class":127},[82,791,137],{"class":99},[11,793,794,797,798,800],{},[35,795,796],{},"Heartbeats and drops are handled for you."," The SDK sends a heartbeat every 30 seconds, and the broker drops a connection it has not heard from for 60 seconds and broadcasts the leave, so a silently dropped client is cleaned up within about a minute. A graceful disconnect leaves immediately. Reconnecting restores the connection's subscriptions but not its room presence, so set it again on ",[79,799,644],{}," (the blueprint SDKs do this for you).",[11,802,803,806,807,810],{},[35,804,805],{},"Presence is per connection."," Each entry is identified by ",[79,808,809],{},"actorTokenId",". If one person can be connected from several devices on the same actor, each connection joins and leaves on its own, so do the reference counting in your application, or give each device its own actor.",[11,812,813,814,819],{},"For global presence, use a ",[815,816,818],"a",{"href":817},"/docs/concepts/lobbies","lobby",". A lobby is an explicit group of rooms, configured in the control plane, and presence set on any of its rooms mirrors into it (a room can belong to at most 10 lobbies). It is observe-only: you cannot set presence on a lobby, and only actors that have set presence on one of its rooms appear in it. Subscribing returns a snapshot to bootstrap a contacts list, then live updates follow:",[68,821,822],{},[71,823,825],{"className":73,"code":824,"filename":75,"language":76,"meta":77,"style":77},"const lobby = client.setApp(APP_SLUG).setLobby('online')\n\n// Snapshot keyed by room, then by actor: { roomId: { actorId: presence } }\nconst snapshot = await lobby.subscribe()\n\nlobby.on('presence:join', ({ roomId, actorId, data }) => showOnline(actorId, roomId, data))\nlobby.on('presence:leave', ({ actorId }) => showOffline(actorId))\nlobby.on('presence:update', ({ actorId, data }) => showOnline(actorId, undefined, data))\n",[79,826,827,855,859,864,883,887,926,950],{"__ignoreMap":77},[82,828,829,831,834,836,838,840,842,844,846,849,851,853],{"class":84,"line":85},[82,830,526],{"class":103},[82,832,833],{"class":146}," lobby",[82,835,190],{"class":103},[82,837,641],{"class":99},[82,839,675],{"class":95},[82,841,100],{"class":99},[82,843,680],{"class":146},[82,845,683],{"class":99},[82,847,848],{"class":95},"setLobby",[82,850,100],{"class":99},[82,852,301],{"class":127},[82,854,150],{"class":99},[82,856,857],{"class":84,"line":92},[82,858,157],{"emptyLinePlaceholder":156},[82,860,861],{"class":84,"line":116},[82,862,863],{"class":88},"// Snapshot keyed by room, then by actor: { roomId: { actorId: presence } }\n",[82,865,866,868,871,873,875,878,881],{"class":84,"line":140},[82,867,526],{"class":103},[82,869,870],{"class":146}," snapshot",[82,872,190],{"class":103},[82,874,193],{"class":103},[82,876,877],{"class":99}," lobby.",[82,879,880],{"class":95},"subscribe",[82,882,647],{"class":99},[82,884,885],{"class":84,"line":153},[82,886,157],{"emptyLinePlaceholder":156},[82,888,889,892,894,896,898,901,904,907,910,912,915,918,920,923],{"class":84,"line":160},[82,890,891],{"class":99},"lobby.",[82,893,558],{"class":95},[82,895,100],{"class":99},[82,897,563],{"class":127},[82,899,900],{"class":99},", ({ ",[82,902,903],{"class":569},"roomId",[82,905,906],{"class":99},", ",[82,908,909],{"class":569},"actorId",[82,911,906],{"class":99},[82,913,914],{"class":569},"data",[82,916,917],{"class":99}," }) ",[82,919,110],{"class":103},[82,921,922],{"class":95}," showOnline",[82,924,925],{"class":99},"(actorId, roomId, data))\n",[82,927,928,930,932,934,936,938,940,942,944,947],{"class":84,"line":166},[82,929,891],{"class":99},[82,931,558],{"class":95},[82,933,100],{"class":99},[82,935,616],{"class":127},[82,937,900],{"class":99},[82,939,909],{"class":569},[82,941,917],{"class":99},[82,943,110],{"class":103},[82,945,946],{"class":95}," showOffline",[82,948,949],{"class":99},"(actorId))\n",[82,951,952,954,956,958,960,962,964,966,968,970,972,974,977,980],{"class":84,"line":181},[82,953,891],{"class":99},[82,955,558],{"class":95},[82,957,100],{"class":99},[82,959,592],{"class":127},[82,961,900],{"class":99},[82,963,909],{"class":569},[82,965,906],{"class":99},[82,967,914],{"class":569},[82,969,917],{"class":99},[82,971,110],{"class":103},[82,973,922],{"class":95},[82,975,976],{"class":99},"(actorId, ",[82,978,979],{"class":146},"undefined",[82,981,982],{"class":99},", data))\n",[11,984,985,986,989,990,993],{},"Custom metadata is first-class: whatever object you pass to ",[79,987,988],{},"room.setPresence"," is what other members receive, and calling it again propagates the change as a ",[79,991,992],{},"presence:update",". There is no presence field in the connect options, so the sequence is always connect first, then set presence on a room. No custom presence server, no polling, no heartbeat management code in your application.",[995,996,997],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":77,"searchDepth":92,"depth":92,"links":999},[1000,1001,1002,1003,1004,1010],{"id":20,"depth":92,"text":21},{"id":62,"depth":92,"text":63},{"id":247,"depth":92,"text":248},{"id":407,"depth":92,"text":408},{"id":429,"depth":92,"text":430,"children":1005},[1006,1007,1008,1009],{"id":434,"depth":116,"text":435},{"id":445,"depth":116,"text":446},{"id":452,"depth":116,"text":453},{"id":466,"depth":116,"text":467},{"id":473,"depth":92,"text":474},"Engineering","2026-03-24","Presence tracking looks simple until you have 10,000 concurrent users across hundreds of rooms. Learn the architecture behind scalable online/offline status, heartbeat timeouts, and per-room vs global presence.",null,"md",{},"/blog/presence-tracking-at-scale","8 min read",{"title":5,"description":1013},"blog/presence-tracking-at-scale","srMOfwKmW0dLo-c0URLCB6TLVkJp6B5UFHb10_Ytp1k",1789869417520]