Repository navigation
Measure workspace presence join fan-out before changing it #495
Description
Summary
Each signed-in tab on a pad answers every presence join and every requestPresenceSync with a broadcast. So one join costs about (4R + 1) · M deliveries, where R is signed-in tabs and M is all tabs on the document. That grows with the square of the room size.
This issue comes from the presence architecture review in #496. It is not a cause of the stuck-Online bug. It is a scale risk on the same workspace:<documentId> channel.
This issue is a dashboard check first. Nothing shows prod near a limit today. The maintainer reads two prod dashboards, then either closes the issue or ships Step A. Step A deletes about 4 lines and is written out in full.
Problem
All presence files are identical at db481158b and at main HEAD, so the permalinks below match the code.
What one join sends today (read from code, not measured)
workspaceIdis the document id (useInitiateDocumentAndWorkspace.ts:9). So each document has one Realtime channel.- Signed-in tabs subscribe with
broadcast: { self: true }(useCatchUserPresences.ts:100-105). Visitors useself: falseand never calltrack()(:73-93). - Every signed-in tab answers both a presence
joinand arequestPresenceSync(useCatchUserPresences.ts:127-131). - On
SUBSCRIBED, a signed-in joiner runstrack(profile), thenshareHeadingPresenceWithRoom, thenrequestPresenceSync(:133-149). - One flush sends up to 2 broadcasts (
workspacePresenceSync.ts:149-166). It sendspresenceonly when the tab has a chat open. It sendspresenceSynconly when its payload is not empty. - The 600 ms debounce flushes at once. A second call inside 600 ms schedules one trailing flush (
:169-198).apps/webapp/src/components/chatroom/CLAUDE.md:133says to keep it. - So a join and a request give each peer 2 flushes, up to 4 broadcasts. The joiner sends up to 5, because its own request echoes back (
self: true).
A profile change, the browser online event, a Realtime auto-rejoin and an avatar upload all repeat this cycle (useCatchUserPresences.ts:36-42, :167-177, useAvatarUpload.ts:42-49). useProfileSync.ts:47 skips equal data, so there is no rejoin loop.
Cost of one signed-in join (upper bound, read from code)
N is tabs on one document, all signed in. The 1x value is assumed, not measured. Tabs per document were never measured.
| Scale | N | Today 4N²+N |
After Step A 2N²+3N |
Note |
|---|---|---|---|---|
| 1x | 3 | 39 | 27 | |
| 10x | 30 | 3,630 | 1,890 | One join is over Pro's 500 messages per second |
| 100x | 300 | 360,300 | 180,900 | 300 tabs is 60% of Pro's 500 connections |
| 1000x | 3,000 | about 36M | about 18M | Needs the 10,000-connection tier and a server-side design |
These are upper bounds. If nobody has a chat open, peers send no presence broadcast. A visitor join costs about (2R + 1) · M. Payload size is not a risk: the track payload is 308 B to 436 B (measured locally).
At 100x and above, the connection cap and the presence limit bind first. Step A does not change them.
Supabase Realtime limits (fetched 2026-10-15)
From Realtime limits and Realtime Messages billing:
| Limit | Free | Pro | Pro, no spend cap / Team |
|---|---|---|---|
| Concurrent connections | 200 | 500 | 10,000 |
| Messages per second | 100 | 500 | 2,500 |
| Presence messages per second | 20 | 50 | 1,000 |
| Monthly Realtime Messages quota | 2M | 5M | 5M |
- "Connections will be disconnected if your project is generating too many messages per second."
- The limits page counts an event as "a WebSocket message delivered to, or sent from a client". So deliveries likely count. The limiter's time window is not documented.
- Billing: "Each broadcast message counts as one message sent plus one message per subscribed client that receives it." The quota also counts database changes and Presence. So the Usage total is a true upper bound on presence traffic.
- Log retention (pricing): Free 1 day, Pro 7, Team 28.
Decisions
Maintainer rulings are posted as a comment.
- What to do now? Ruled 2026-10-15: run the dashboard check (Steps 0 to 3), then close or ship Step A. It takes about 10 minutes and needs no SQL. No alert reads Supabase Realtime errors or usage today, so a "reopen on error" trigger would never fire.
rules-realtime.ymlcovers only Hocuspocus sockets.- Other option: close now as "not justified at current scale", with no check.
- Other option: ship Step A now, with no check. It is a safe cleanup, but it gains almost nothing at 1x.
- First change on a Go? Ruled 2026-10-15: Step A (drop the
joinresponder). A single-responder design adds a new way to fail to code that has no tests.
Depends on
- Nothing blocks the dashboard check.
- Use one client presence rule so the @ picker agrees with the avatars #493 also edits
apps/webapp/src/services/workspacePresenceSync.tsand the samechatroom/CLAUDE.md:133bullet. If Step A ships, land Use one client presence rule so the @ picker agrees with the avatars #493 first. Then apply Step A on top of it. Find the lines by their text, because Use one client presence rule so the @ picker agrees with the avatars #493 moves them. - No interaction with Stop exposing every user's online status and last-active time #434 (migration
20261009120500), Bring back push on Chrome and Android devices the server switched off #417 or [Tracking] Push notification follow-ups after #417 #447. Expire a peer's typing state when its stop signal never arrives #342 editsuseBroadcastListener.ts, a different file.
Fix plan
No SQL, no supabase.ts regeneration and no prod hand-paste in any step.
Step 0. Plan (maintainer, prod dashboard)
Record the Supabase plan prod is on, and its log retention window.
Step 1. Realtime errors (maintainer, Logs Explorer)
Pick the Realtime source, or query the logs table with source = 'realtime_logs' (log field reference). Search the whole retention window. We cannot tell which naming set prod prints, so search event_message for every term:
| Search term | Meaning | Action |
|---|---|---|
tenant_events, MessagePerSecondRateLimitReached |
Message rate | Go for this issue |
PresenceRateLimitReached, ClientPresenceRateLimitReached |
Presence limit | Record it. Not a Go. See Out of scope (re-track). |
too_many_joins, too_many_connections, too_many_channels, JoinsRateLimitReached, ClientJoinRateLimitReached, ConnectionRateLimitReached, ChannelRateLimitReached |
Join, connection or channel limit | Record it. Not a Go, because Step A changes none of them. See Out of scope. |
Record the window you searched and the count per term.
Step 2. Usage (maintainer, Org Usage page)
For the last full billing cycle, record:
- total Realtime Messages, and that total as a percent of the plan quota;
- Realtime Peak Connections against the plan cap (Pro 500 with spend cap).
Step 3. Go or no-go (maintainer)
Post the Step 0 to 2 numbers as a comment. Go if either holds:
- Step 1 shows a message-rate hit (
tenant_eventsorMessagePerSecondRateLimitReached). - Step 2 Realtime Messages for the cycle are at or above 25% of the plan quota (Pro: 1.25M of 5M).
Rule 2 uses the total, which also counts chat and notification traffic. So it can give a false Go, but never a false No-go. A false Go costs only Step A.
No-go otherwise. Close the issue as "not justified at current scale".
Step A. On Go only: drop the join responder
Every joiner already sends requestPresenceSync, and visitors never fire join. So the join responder is redundant. Removing it cuts each peer to one flush per join, and the cost falls to about (2R + 3) · M. Auto-rejoin still re-runs the SUBSCRIBED callback, so rejoin coverage stays.
One webapp commit, with these edits:
-
apps/webapp/src/hooks/useCatchUserPresences.ts(now:127-131). Delete the lineonPresenceJoin: (channel) => shareHeadingPresenceWithRoom(channel, profile),(now:129). The call becomes:attachWorkspacePresenceListeners(messageSubscription, { ...presenceDeps, onRequestPresenceSync: (channel) => shareHeadingPresenceWithRoom(channel, profile) })
-
apps/webapp/src/services/workspacePresenceSync.ts. Remove the now-dead option in three places:- the type field
onPresenceJoin?: (channel: RealtimeChannel) => void(now:244); onPresenceJoinin the destructuringconst { setOrUpdateUserPresence, onPresenceJoin, onRequestPresenceSync } = deps(now:252);- the call
onPresenceJoin?.(channel)(now:259).
Keep the 600 ms debounce and the
channelIdmerge. - the type field
-
Same file, line 5. Change the comment to
/** Debounce window for heading presence re-broadcasts (requestPresenceSync storms). */. -
apps/webapp/src/components/chatroom/CLAUDE.md:133(the anon-viewers bullet), in the same commit:- Replace "On subscribe /
requestPresenceSync/ peerjoin, existing viewers" with "On subscribe and onrequestPresenceSync, existing viewers". Keep the debounce sentences.
- Replace "On subscribe /
Do not move channelId into track(). The presence limit (Pro 50/s) is tighter than the message limit (Pro 500/s), so each chat switch would cost a presence message. TOC avatars also key on the broadcast channelId.
Risk: a requestPresenceSync is a best-effort broadcast, but a join diff comes from the server. If a request is lost, heading faces stay hidden until a peer switches chat.
Deploy: one webapp deploy. One week later, rerun Step 1. After the next full billing cycle, rerun Step 2. Post both results.
Rollback: revert that one commit (code and doc line together) and redeploy the webapp.
If Step A is not enough, file a single-responder design as a new issue.
Verify
Dashboard check: the Go or no-go comment holds every Step 0 to 2 number and names the rule that decided it.
Step A, if built:
grep -rn onPresenceJoin apps/webapp/src # expect no output
bun run --filter @docs.plus/webapp typecheck # expect exit 0
bun run lint # expect exit 0
bun run format # expect exit 0
bun run check:agent-docs # expect exit 0Then, on the local stack, with two accounts A and B in two browser profiles that do not share a session:
- Baseline, before Step A. Add a temporary
console.count('flush')as the first line offlushHeadingPresenceShareinworkspacePresenceSync.ts(now:149). In profile 1, open a pad as account A and open a heading chat. Keep the composer idle, because typing sends activity broadcasts. A's own subscribe also flushes, so wait 5 s, then note the count in A's console. - In profile 2, open the same pad as account B. Wait 2 s, then note how much A's count rose. From code, expect 2: one flush for
join, one for the request. - After Step A. Apply Step A and keep the temporary line. Close B's tab, reload A's tab, and repeat steps 1 and 2. The rise per join must be exactly 1 less than in step 2. That difference proves the removed responder is gone.
- On B's tab, account A's face must show in each reader of
usersPresence. It must also show on the heading row of A's open chat.- Pad header
PresentUsers(PresentUsers.tsx:7). - TOC rows and the doc-title row (
TocRowTrail.tsx:37). - Chat
ParticipantsList(ParticipantsList.tsx:13). - The composer mention list (
MentionList.tsx:39-57). - These readers drop the viewer's own id (
selectPresenceOthers), so B must be a different account from A.
- Pad header
- Remove the
console.countline. Rungrep -rn "console.count('flush')" apps/webapp/srcand expect no output.
Acceptance criteria
- A comment records the plan, the log window searched, and the hit count for each Step 1 term.
- The comment records total Realtime Messages as a percent of quota, and Peak Connections against the plan cap.
- The comment states Go or no-go, and which rule decided it.
- On no-go, the issue is closed as "not justified at current scale".
- On Go, Step A lands as one commit with the code and the
chatroom/CLAUDE.md:133edit. - On Go,
grep -rn onPresenceJoin apps/webapp/srcprints nothing. - On Go, A's flush count per join drops by exactly 1 after Step A (Verify steps 2 and 3).
- On Go, every reader in Verify step 4 shows account A's face on account B's tab.
- On Go, Step 1 is rerun one week after the deploy, and Step 2 after the next full billing cycle. Both results are posted.
Out of scope
- The project-wide re-track after a Realtime restart. Every signed-in tab in the project calls
track()again at once. Pro allows 50 presence messages per second. Neither change here touches it. Not filed. The maintainer files it on a Step 1 presence-limit hit. The maintainer also files it if Peak Connections reach 80% of the cap (Pro: 400 of 500). - A join, connection or channel limit hit in Step 1. Step A does not change them. Not filed. The maintainer files it on a hit.
- Whether the 11-key tracked object passes the 10 "presence keys per object" limit. The docs do not define that limit. The object is the 10
USER_PROFILE_COLUMNS(getUserById.ts:16-17) plusstatus(applySignedInProfile.ts:32). It is not fan-out. Not filed. If pad header faces go missing while every chat is closed, the maintainer files a bug. - A real track-error signal.
track()resolves'error'or'timed out'and never rejects, so the.catchatuseCatchUserPresences.ts:143-145never runs. Not filed. - The 3 s activity keepalive (
apps/webapp/src/components/chatroom/components/MessageComposer/helpers/handleTypingIndicator.ts:15). It uses the same channel but is not presence fan-out. - Measuring tabs per document from
document_views. Its rows are lower bounds: one row per session, document and day, and short tabs keepduration_ms0. So they cannot prove a No-go. - Server-side batching for 1000x scale. It needs its own design.
- Who counts as present, and the stale
OFFLINEfaces. Use one client presence rule so the @ picker agrees with the avatars #493 owns the client presence rule. - The
users.statuswrite path. Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489 and Fix the Status writer's tab race, reload gap and silent errors #492 own it. This issue changes no SQL. - The bell row. Make the chat bell use the online lease instead of the raw status flag #490 owns the bell's online gate.
Generated by Claude Code
Activity
HMarzban commentedon Oct 15, 2026
Maintainer ruling, 2026-10-15
Decision 1: measure first. Run the dashboard check (Steps 0 to 3), then close the issue or ship Step A.
Decision 2: on a Go, Step A comes first (drop the
joinresponder).All decisions in this issue are now ruled.
HMarzban commentedon Oct 21, 2026
Moved to the backlog, 2026-10-21
Maintainer ruling: this is not a problem today. Nothing shows production near a Realtime limit, and pads rarely hold more than a few people at once. It is worth an optimisation later, so it stays open, with its plan and its 2026-10-15 rulings intact.
To pick it up again: run Steps 0 to 3 (the two dashboard reads), then close it or ship Step A, as ruled.