Skip to content

Measure workspace presence join fan-out before changing it #495

Open

Description

@HMarzban

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)

  • workspaceId is 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 use self: false and never call track() (:73-93).
  • Every signed-in tab answers both a presence join and a requestPresenceSync (useCatchUserPresences.ts:127-131).
  • On SUBSCRIBED, a signed-in joiner runs track(profile), then shareHeadingPresenceWithRoom, then requestPresenceSync (:133-149).
  • One flush sends up to 2 broadcasts (workspacePresenceSync.ts:149-166). It sends presence only when the tab has a chat open. It sends presenceSync only 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:133 says 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.

  1. 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.yml covers 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.
  2. First change on a Go? Ruled 2026-10-15: Step A (drop the join responder). A single-responder design adds a new way to fail to code that has no tests.

Depends on

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:

  1. Step 1 shows a message-rate hit (tenant_events or MessagePerSecondRateLimitReached).
  2. 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:

  1. apps/webapp/src/hooks/useCatchUserPresences.ts (now :127-131). Delete the line onPresenceJoin: (channel) => shareHeadingPresenceWithRoom(channel, profile), (now :129). The call becomes:

    attachWorkspacePresenceListeners(messageSubscription, {
      ...presenceDeps,
      onRequestPresenceSync: (channel) => shareHeadingPresenceWithRoom(channel, profile)
    })
  2. apps/webapp/src/services/workspacePresenceSync.ts. Remove the now-dead option in three places:

    • the type field onPresenceJoin?: (channel: RealtimeChannel) => void (now :244);
    • onPresenceJoin in the destructuring const { setOrUpdateUserPresence, onPresenceJoin, onRequestPresenceSync } = deps (now :252);
    • the call onPresenceJoin?.(channel) (now :259).

    Keep the 600 ms debounce and the channelId merge.

  3. Same file, line 5. Change the comment to /** Debounce window for heading presence re-broadcasts (requestPresenceSync storms). */.

  4. apps/webapp/src/components/chatroom/CLAUDE.md:133 (the anon-viewers bullet), in the same commit:

    • Replace "On subscribe / requestPresenceSync / peer join, existing viewers" with "On subscribe and on requestPresenceSync, existing viewers". Keep the debounce sentences.

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 0

Then, on the local stack, with two accounts A and B in two browser profiles that do not share a session:

  1. Baseline, before Step A. Add a temporary console.count('flush') as the first line of flushHeadingPresenceShare in workspacePresenceSync.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.
  2. 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.
  3. 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.
  4. 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.
  5. Remove the console.count line. Run grep -rn "console.count('flush')" apps/webapp/src and 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:133 edit.
  • On Go, grep -rn onPresenceJoin apps/webapp/src prints 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) plus status (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 .catch at useCatchUserPresences.ts:143-145 never 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 keep duration_ms 0. 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 OFFLINE faces. Use one client presence rule so the @ picker agrees with the avatars #493 owns the client presence rule.
  • The users.status write 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

  1. added
    ChatRelated to chat features
    PerformanceQuery cost, load, and capacity
    on Oct 14, 2026
  2. HMarzban commented on Oct 15, 2026

    @HMarzban
    CollaboratorAuthor

    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 join responder).

    All decisions in this issue are now ruled.

  3. modified the milestones: v2.2.0, v2.3.0 on Oct 18, 2026
  4. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    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.

  5. removed this from the v2.3.0 milestone on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChatRelated to chat featuresPerformanceQuery cost, load, and capacity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions