Repository navigation
馃搶 RTC request and IOPS cost: data for hosts聽#444
Description
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
- 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.
- 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_idandwp_get_session_token()are available server-side and dropped before the response. - 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
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"]
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 Defer the poll loop until a second person is present.
hasCollaboratorsderives from the poll response today, so the signal has to arrive on a channel that is already running; feat: add RTC collaboration hooks and server authority聽#290 is one. The editing loop still needs its own rate once active. - gutenberg Reconsider the interval clamp for the solo case.
getFilteredPollingInterval()clamps filtered values to the default throughMath.min, added deliberately in RTC: Prevent slower polling filters聽gutenberg#78811 (closing RTC: remove the ability to alter polling intervals聽gutenberg#77500) so a host cannot slow active collaboration into feeling broken. The clamp is one-directional:sync.pollingManager.pollingIntervalWithCollaboratorscan be filtered down to 250ms but not up to 2s, so a host can add requests and not shed them. The rationale applies to a room with two people; for a lone editor a wider interval costs no collaborative responsiveness. Depends on the item above. - gutenberg Return identity above the client grain, so a room can distinguish people from connections.
wp_user_idis already stamped andwp_get_session_token()is already available; both are dropped before the response. Fixes the tab multiplier, the connection limit and RTC: Same user with the post open in multiple tabs appears multiple times in the collaborators list聽gutenberg#78700 together, server-side, with no client coordination. - gutenberg Coalesce polling across tabs. Cheaper than the above per request, but needs client-side election;
presence-poll-coordinator.jsis a working reference. - gutenberg Read awareness once per request instead of in both
check_permissions()andprocess_awareness_update(). - gutenberg Skip
set_awareness_state()when merged state is unchanged. perf: skip Who's Online payload when room state is unchanged聽#288 is the response-side pattern; perf: skip presence writes that would only move the timestamp聽#450 is the write-side one. - gutenberg Revisit
AWARENESS_TIMEOUTat 30s, which bounds any widened interval. It is a floor, chosen so a throttled inactive tab is not dropped from awareness. Raising it is safe for liveness and costly for ghosts: section 3 shows a departed client held in the room pins everyone else to the collaborator interval. Presence's 150s is not a target to copy; a stale presence row costs a stale avatar, a stale awareness entry costs the room the fast rate. - gutenberg-sync-engines Re-measure sections 2 and 3 against it on
http-polling, with the Presence API active and without, since the awareness backend changes which queries a poll runs. Both are currently measured against sync-storage at the pinned revision. - gutenberg-sync-engines The awareness backend reads the room twice for every write, once in the permission check and once inside the write itself. Same duplicate read as the Gutenberg item above, and with the Presence API active both halves are real queries. The other half of An awareness write costs three queries per client per tick when two would do聽#517. #104 removes the read inside the write; the one in the permission check remains.
- presence-api Collaboration edge is only exposed to PHP, so the client cannot act on it聽#451. The collaboration edge reaches the client in the Heartbeat response, and as a
wp.hooksaction since feat: expose the room's collaborator count as JS hooks聽#503 and refactor: name the collaboration JS actions for the edges they fire on聽#511, so the editor can decide whether to start the sync loop. Prerequisite for the first Gutenberg item. - presence-api wp_set_presence() always stamps date_gmt with now, so a relay cannot preserve the original time聽#452. An explicit
$date_gmtonwp_set_presence()(functions.php#L504), so a caller relaying awareness for other clients can preserveupdated_at. - presence-api Consumers hand-roll an availability check for this plugin聽#518. A public
wp_presence_is_available(), so a caller can tell an unavailable plugin from an empty room instead of checking four functions and still not knowing whether the table exists. - presence-api Collaboration edge state writes to
wp_optionson hosts without a persistent object cache聽#512. The collaboration edge count no longer writes awp_optionsrow per heartbeat per room on a site with no persistent object cache. - presence-api perf: keep the collaboration edge state in the presence table聽#514. It moved into the presence table behind a reserved
_prefix, so the room read a poll already makes carries it, hidden from callers. - presence-api fix: keep a client that is still pinging out of the idle state聽#522. The write skip is capped at 30 seconds however high the TTL goes, so a raised TTL can no longer leave a live client's row unwritten for minutes.
- presence-api Nothing tells a consumer how stale a presence row can be聽#525. The 30 second cap is published in the README, so a consumer no longer has to reverse-engineer it from the source to size a liveness window.
- presence-api Every consumer writes the same loop to get its own rows out of a room聽#516.
wp_get_presence()takes a client id prefix as a third argument, so a caller stops looping over rows that are not its own. It shipped in 0.7.0, so only a consumer supporting older versions still needs its own filtering. - presence-api A site's wp_presence_default_ttl filter overrides a consumer's own timeout window聽#515. #540 stopped the filter running over an explicit argument and #541 gave that argument a
date_gmtfloor of its own on every read, sowp_get_presence( $room, 30 )returns rows no older than 30 seconds under any site TTL. The backend's second ageing pass can come out. - presence-api An awareness write costs three queries per client per tick when two would do聽#517. Two queries per client per tick since #542 moved the redundancy decision into the upsert.
wp_presence_exchange()andwp_presence_leave()shipped in 0.8.0 (#546).
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
chriszarate commentedon Sep 1, 2026
Using
BroadcastChannelto coalesce is really cool! Have you observed any issues with lost data? What happens when the leader tab is closed with updates queued or mid-flight?Just noting that the goal of this value was to fit under the browser throttling threshold for inactive tabs, so that inactive tabs are not immediately removed from awareness. Perhaps it could be raised, but 150s is potentially too high.
josephfusco commentedon Sep 2, 2026
I had the timeout backwards. 30s is a floor, sized so a tab the browser has slowed down still checks in before it expires. Both places in the issue are updated.
On coalescing: the elected tab isn't holding anything on behalf of the others. The rest stay quiet while it owns the lock, so when it closes there's nothing of theirs sitting inside it to lose. What dies with it is one in-flight request asking who's in the room. The next tab to win the lock asks again straight away, without waiting for its first scheduled poll.
Sync is a different shape. Each tab's edits exist only in that tab, so there's nothing to go re-ask the server for. A leader would have to hold the other tabs' edits for them, keep them until the server confirms, then hand off whatever wasn't confirmed when it dies.
Closing the leader is covered end to end. Three Playwright specs open a second tab, check it stays quiet, close the leader, then watch the second tab take over: ping, active posts, stale screen. Closing it with a request already in flight isn't covered.
josephfusco commentedon Sep 18, 2026
Yes, as running code. Nobody had written it up, so that is now WordPress/gutenberg#83165.
Most of what trunk exposes only tunes behaviour.
__unstable_wp_sync_storageis the one seam that substitutes a component, and it covers awareness and the CRDT update log together, so a plugin cannot supply one without the other. There is no server-side transport or engine seam.experiment/sync-enginesadds both, and gutenberg-sync-engines runs on a Gutenberg copy carrying them.[-]Tracking: RTC polling cost and the Gutenberg sync / Presence API overlap[/-][+]馃搶 Tracking: RTC polling cost and the Gutenberg sync / Presence API overlap[/+][-]馃搶 Tracking: RTC polling cost and the Gutenberg sync / Presence API overlap[/-][+]RTC request and IOPS cost: data for hosts[/+][-]RTC request and IOPS cost: data for hosts[/-][+]馃搶 RTC request and IOPS cost: data for hosts[/+]josephfusco commentedon Sep 27, 2026
Everything on this repo's side is done; the remaining Gutenberg and gutenberg-sync-engines items are in #647.