Skip to content

📌 RTC request and IOPS cost: data for hosts #444

Description

@josephfusco

The request, query and IOPS cost of real-time collaboration polling, for hosts deciding whether to support RTC. Assembled from host feedback in #feature-realtime-collaboration.

Status, the proposed 7.2 direction and the Presence API path to core moved to #550.


RTC has never shipped in a WordPress core release. It was committed to trunk during 7.0 development (r61689) and cut before 7.0 shipped (r62334, #65205); neither trunk nor the 7.0 branch has src/wp-includes/collaboration/. It ships in the Gutenberg plugin, lib/experimental/collaboration/ as of v23.9.0-rc.1, behind the gutenberg-real-time-collaboration experiment. Everything below reads the plugin.

Findings

  1. A solo editor sends about 840 requests and 5,000 queries an hour to establish that nobody else is on the post (§2, §3). Presence API carries that signal on a Heartbeat that already runs.
  2. Two windows on the same post measure 6,360 requests an hour for one person, because awareness is keyed per connection rather than per user (§3). wp_user_id and wp_get_session_token() are available server-side and dropped before the response.
  3. Moved to 📌 Tracking: Presence API path to core #550.

The hosting team opened a call for feedback on 2026-09-24 asking hosts to test three server-aware sync engine candidates in gutenberg-sync-engines: Yjs-server, Distributed Editing (DE-RTC), and intent log. All three sit above the awareness seam, so any of them can run on wp_presence. The awareness backend is a separate axis from engine choice and the post does not mention it, so finding 1 above will not be measured unless the test instructions make the backend an explicit toggle.

Pinned revisions
Repo Pinned at Role
gutenberg e7b9583 trunk Sync client and server
presence-api v0.6.0 tag wp_presence, awareness storage
sync-storage d9201aa main WP_Sync_Storage adapter. Archived 2026-09-18
gutenberg-sync-engines 2006b23 trunk WP_Sync_Storage adapter and awareness backend, successor to sync-storage

distributed-rtc-performance-testing is the benchmark harness. It measures server cost per request (wall time, $wpdb->num_queries, memory) across four storage approaches, driven by curl clients at a POLL_DELAY the operator supplies. Cadence and tab count are inputs there, so the request volume in §3 is not something it measures. Trac #64696 is the ticket gutenberg-sync-engines answers: open, milestone 7.2, on where high-frequency collaboration data should live. Trac #65205 is where RTC was cut from 7.0 during development: closed, milestone 7.0.


1. Reported load

Reported in #feature-realtime-collaboration: a managed host's platform-side testing put RTC at 31M (best case) to 267M (worst case) additional daily requests across their fleet, against an implementation that started the poll loop on every post edit view. The model scaled observed traffic to edit URLs in scope for RTC, with idle-window and other factors layered on. The host rates their own confidence in it as medium.

Those figures were modelled before both polling intervals were widened 4x. Until WordPress/gutenberg#76704 the defaults were 1s solo and 250ms with collaborators; they are now 4s and 1s (config.ts#L45-L46). A 250ms collaborator interval is 14,400 requests per hour per client. Any figure modelled against those defaults needs rescaling before it describes current code, and the host has offered to remodel against it.

Of the cost factors named, IOPS and read latency were identified as usually the largest, so the sections below count queries per poll rather than PHP execution time.

RTC needs a cadence faster than and separate from general wp-admin presence polling, which bounds how far the two can merge. Presence can carry discovery; the editing loop still needs its own rate once active.


2. Cost per poll

Requests. One poll loop per open editor: 4s alone, 1s with collaborators, 25s when the tab is hidden (config.ts#L45-L46, #L78). At 4s that is a nominal 900 requests per hour per editor, measured at 840, authenticated POSTs, none cacheable.

Presence API adds none. It rides Heartbeat on screens where it already runs: heartbeat_received server-side (presence-api.php#L401-L404) and the heartbeat-send event client-side (presence-ping.js#L102). It has no endpoint of its own, and the only interval it sets is a wider one for idle rooms (#L44-L56).

Queries. Per room, per poll, through POST /wp-sync/v1/updates with sync-storage active, at the revisions pinned above. Not re-measured against gutenberg-sync-engines:

sequenceDiagram
    participant T as Editor tab
    participant S as WP_HTTP_Polling_Sync_Server
    participant W as wp_posts
    participant P as wp_presence
    participant C as wp_collaboration

    T->>S: POST /wp-sync/v1/updates
    Note over S,P: check_permissions()
    S->>P: SELECT awareness
    S->>W: SELECT post (cap check)
    Note over S,P: process_awareness_update()
    S->>P: SELECT awareness
    S->>P: INSERT awareness row (freshest only)
    Note over S,C: get_updates()
    S->>C: SELECT updates after cursor
    S->>C: SELECT total update count
    S-->>T: awareness map, updates, cursor
    Note over T,C: 6 queries, flat in the number of clients
Loading

Six per room per poll, on top of whatever WordPress itself runs to serve the request. Against the measured request rates in §3, that is ~5,000/hour for one editor and ~56,000/hour for three on the 1s collaborator interval; 5,400 and 64,800 if the intervals are taken as exact.

tests/e2e/polling-cost.spec.ts in this repo drives the endpoint and asserts the count is identical at one client and at three; a SAVEQUERIES dump of the three-client poll is the sequence above. The wp_posts read is a cache miss here and would be a hit on a site with a persistent object cache, so five of the six are unconditional.

Awareness is read twice because the permission check and the merge each fetch it independently (#L226, #L344).

Row lifetime: Presence owns it, Gutenberg owns visibility, they disagree 5x

Presence API owns writes, TTL and cleanup for every row in wp_presence. The sweep is table-wide WHERE date_gmt < cutoff (functions.php#L929), batched 1,000 rows across at most 10 passes, both filterable, so sync-written rows expire on the same path as any other.

Visibility is separate. Gutenberg drops entries older than AWARENESS_TIMEOUT, 30s, every time it reads (#L36). Presence holds rows for WP_PRESENCE_DEFAULT_TTL, 150s (presence-api.php#L67), matching the core post lock window. The 30s figure is the binding one, and it is a floor: sized so an inactive tab whose timers the browser has throttled still refreshes inside it. Any widened polling interval has to stay under it.

A third number sits between the two. Since #450 an unchanged row is not rewritten until it reaches wp_presence_refresh_threshold(), 15 seconds at the default TTL. #522 caps it at 30 however high the TTL goes, so a raised TTL no longer stretches the gap into minutes. The cap and AWARENESS_TIMEOUT are both 30, which leaves no margin between them, and that is why the gutenberg-sync-engines awareness backend still stamps its own timestamp and refreshes at a third of the window. Passing an explicit $date_gmt is what turns the skip off.

Since #541 a row carries its own expires_gmt, so a consumer on its own transport can pass $expires_in, own the row's lifetime outright and remove it when its client leaves. That path does not reach wp_presence_refresh_threshold() or wp_presence_next_tick_gap(), both of which read Heartbeat request fields this consumer never sends.


3. One human, two tabs

Awareness entries are keyed by Y.Doc.clientID (polling-manager.ts#L1024, README.md#L178), which Yjs assigns per document instance. A second tab on the same post is a second client. Nothing in packages/sync/src coordinates across tabs: no BroadcastChannel, no navigator.locks.

flowchart TD
    A["Tab A<br/>clientID 1111"] --> R
    B["Tab B<br/>clientID 2222"] --> R
    R["room awareness<br/>2 entries, 1 person"]

    R --> H["length > 1<br/>hasCollaborators"]
    R --> L["count > limit of 3"]
    R --> M["clientId !== this.clientID"]

    H --> H2["1s interval in the visible tab<br/>3,120 req/hr measured, for one person"]
    L --> L2["4th tab never syncs"]
    M --> M2["you are your own collaborator"]

    R -.-> W["known server-side, never returned:<br/>same wp_user_id, same session token"]
Loading

Three consequences, none of which requires a second person.

The fast loop opens, and a hidden tab holds it open. hasCollaborators is set from Object.keys( room.awareness ).length > 1 on the primary room (#L727-L731) and selects the 1s interval (#L778-L784).

The interval is per tab and gated on document.visibilityState (#L422), so the second tab is usually hidden and polls at 25s rather than 1s. It still counts. A 25s interval refreshes an entry with five seconds to spare against the 30s AWARENESS_TIMEOUT applied on read (#L355), so a forgotten background tab never ages out and pins the foreground tab to the collaborator rate indefinitely.

One person, one post Derived Measured
One tab 900 840
Two tabs, one window (one hidden at 25s) 3,744 3,120
Two visible windows 7,200 6,360

The measured column is tests/e2e/polling-cadence.spec.ts in this repo: a real editor, real tabs, requests counted off the browser over a 30-second window and scaled. Every case lands under its derived figure, for the reason in §1: each cycle costs the interval plus a round trip. Observed collaborator gaps are 1,054-1,120ms against a nominal 1,000ms. That margin widens on a slower server, so the derived figures are a ceiling and a loaded host sheds requests rather than queueing them. The 30-second window truncates at both ends, which is coarsest on the 25s hidden row.

The hidden-tab row is measured with document.visibilityState overridden and a visibilitychange dispatched, the signal the loop branches on (#L422); headless Chromium leaves every page visible otherwise. The hidden tab sent 1 request in 30 seconds and the visible one sent 25: a backgrounded tab holds the foreground tab at the collaborator rate while contributing almost no requests itself.

The fourth tab is refused. checkConnectionLimit() counts the same keys against DEFAULT_CLIENT_LIMIT_PER_ROOM, 3 (#L398-L404, config.ts#L3). The check runs on connect only (#L385-L390), so it is the newest tab that fails to sync.

The collaboration UI renders for a solo user. isMe is clientId === this.clientID (awareness-state.ts#L304). Each tab matches only its own entry, so every other tab of yours is a stranger to it, and you appear in your own collaborator list. Open as gutenberg#78700.

Three grains of identity, all already reachable

Awareness carries only the finest one, so the client cannot tell any of them apart.

Grain Signal Where it comes from Status today
Person get_current_user_id() server, every request stamped onto the entry (#L368), then dropped from the response (#L375-L379)
Browser or device wp_get_session_token() server; one token per login session, shared by every tab in that browser unused
Tab Y.Doc.clientID client the only key awareness has

The person and device grains are both server-side, so no client cooperation is required. Two tabs in one browser share a session token and collapse server-side. One person on a laptop and a phone shares a wp_user_id but not a token: two CRDT clients, one user.

Do not return the raw token; it is the session identifier from the auth cookie. A per-room hash of it is enough to group by, and is all the client needs.

The display side is being decided in gutenberg#78700, where the thread has converged on not collapsing the list, since a second device carries its own cursor. With only the client grain available, a second device and a second tab are indistinguishable, so that decision applies to both as one preference. Returning the middle grain separates them.

The server already compares wp_user_id per client in the permission check (#L228), so the value is loaded either way. WP_Sync_Post_Meta_Storage stores and returns the awareness array verbatim (#L112-L141), so this works on default storage and does not depend on Presence API.


4. Where the two overlap

Moved to #550.


5. The reported cost factors

Factor Status
Requests per second See §2 and §3: 840/hour per solo editor, 3,120 for one person with a second tab open. Presence adds none
MySQL queries per call See §2: 6 per room per poll against sync-storage, flat from one client to three. gutenberg-sync-engines measures its own idle poll at 2 with a persistent object cache and 6 without, but §2's and §3's harnesses have not been re-run against it
Cache invalidation Mitigated in r62099. See below
Disk per room wp_collaboration swept daily at 7 days, wp_presence at 150s. gutenberg-sync-engines schedules nothing: each engine trims its own log during the request that compacts it
CPU and cold opcache, editor count before requests queue, cleanup cron on a low-traffic site Not established, see §7

On cache invalidation. The site-wide last_changed bump reported in #64696 was mitigated in r62099: post meta storage now writes through $wpdb instead of the meta API, so wp_cache_set_posts_last_changed() no longer fires per awareness write (#L88-L99, #L163-L185). The ticket is open at milestone 7.2, punted there because collaborative editing missed 7.0 and then 7.1, not because a fix is in flight. The agreed direction is still moving the data off post meta onto a custom table rather than working around the meta API; gutenberg-sync-engines is that direction.


6. Candidate work

Work that lowers the cost above, roughly ordered by expected reduction within each owner.

gutenberg

Three of those seven describe behaviour gutenberg-sync-engines has already changed. It ships its own copy of the polling provider and its own transport, so it can answer an item without Gutenberg answering it.

Item gutenberg-sync-engines
Defer the poll loop until a second person is present Done. An editor working alone sends nothing beyond the heartbeat, and a second person is noticed on the heartbeat instead (#72)
Reconsider the interval clamp for the solo case Partly. A site setting raises the solo interval, because it is applied with Math.max before the clamp. The sync.pollingManager.pollingInterval filter is still clamped down by the same Math.min
Return identity above the client grain Open. wp_user_id is stamped on the entry and dropped again when the response is built, exactly as §3 describes
Coalesce polling across tabs Open. Tabs talk to each other over an advisory channel, but no tab is elected to poll for the others, and there is no Web Locks or BroadcastChannel use in its client code
Read awareness once per request Open. The permission check reads the room and the write reads it again
Skip the awareness write when merged state is unchanged Done. An idle poll is read-only (#94)
Revisit AWARENESS_TIMEOUT at 30s Open. Still 30, in both the polling transport and the presence lane

Finding 1 at the top of this issue, a solo editor spending 840 requests an hour to learn that nobody else is there, is fixed in the plugin and unchanged in Gutenberg, so that number still describes Gutenberg's own sync package.

presence-api

#451 and #452 came from #444. Six were found while building the gutenberg-sync-engines awareness backend against this plugin, each one something that plugin now works around in its own code: #518, #512, #525, #515, #517 and #516. #514 and #522 came out of this plugin's own work and change what a consumer sees.

What gutenberg-sync-engines already answers moved to #550.


7. Not established

How much deferral takes off the 31M to 267M. Depends on how often two people edit the same post at the same time, and on how often one person has the same post open twice, since section 3 shows both land on the same path. Nothing in these repositories measures either.

CPU, queueing thresholds and low-traffic cron behaviour. No harness covers this path. Both cleanup schedules exist, but WP-Cron only runs on a request, so neither is guaranteed to fire on a quiet site.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Needs DiscussionAnything that needs a discussion/agreementPerformanceWork relates to query load, cache behavior, or scaling[Area] Heartbeat 💓Issues for the heartbeat subsystem[Type] EnhancementA suggestion for improvement of an existing feature

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions