You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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_id and wp_get_session_token() are available server-side and dropped before the response.
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.
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).
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)
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.
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
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. hasCollaborators derives 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 through Math.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.pollingIntervalWithCollaborators can 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 Coalesce polling across tabs. Cheaper than the above per request, but needs client-side election; presence-poll-coordinator.js is a working reference.
gutenberg Read awareness once per request instead of in both check_permissions() and process_awareness_update().
gutenberg Revisit AWARENESS_TIMEOUT at 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-apiConsumers 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-apiEvery 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.
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
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.
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 thegutenberg-real-time-collaborationexperiment. Everything below reads the plugin.Findings
wp_user_idandwp_get_session_token()are available server-side and dropped before the response.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
e7b9583trunkv0.6.0tagwp_presence, awareness storaged9201aamainWP_Sync_Storageadapter. Archived 2026-09-182006b23trunkWP_Sync_Storageadapter and awareness backend, successor to sync-storagedistributed-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 aPOLL_DELAYthe 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_receivedserver-side (presence-api.php#L401-L404) and theheartbeat-sendevent 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/updateswith 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 clientsSix 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.tsin this repo drives the endpoint and asserts the count is identical at one client and at three; aSAVEQUERIESdump of the three-client poll is the sequence above. Thewp_postsread 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-wideWHERE 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 forWP_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 andAWARENESS_TIMEOUTare 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_gmtis 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 reachwp_presence_refresh_threshold()orwp_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 inpackages/sync/srccoordinates across tabs: noBroadcastChannel, nonavigator.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.
hasCollaboratorsis set fromObject.keys( room.awareness ).length > 1on 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 30sAWARENESS_TIMEOUTapplied on read (#L355), so a forgotten background tab never ages out and pins the foreground tab to the collaborator rate indefinitely.The measured column is
tests/e2e/polling-cadence.spec.tsin 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.visibilityStateoverridden and avisibilitychangedispatched, 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 againstDEFAULT_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.
isMeisclientId === 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.
get_current_user_id()wp_get_session_token()Y.Doc.clientIDThe 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_idbut 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_idper client in the permission check (#L228), so the value is loaded either way.WP_Sync_Post_Meta_Storagestores 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
wp_collaborationswept daily at 7 days,wp_presenceat 150s. gutenberg-sync-engines schedules nothing: each engine trims its own log during the request that compacts itOn cache invalidation. The site-wide
last_changedbump reported in #64696 was mitigated in r62099: post meta storage now writes through$wpdbinstead of the meta API, sowp_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.
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.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.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.presence-poll-coordinator.jsis a working reference.check_permissions()andprocess_awareness_update().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.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.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.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.$date_gmtonwp_set_presence()(functions.php#L504), so a caller relaying awareness for other clients can preserveupdated_at.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.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._prefix, so the room read a poll already makes carries it, hidden from callers.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.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.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.
Math.maxbefore the clamp. Thesync.pollingManager.pollingIntervalfilter is still clamped down by the sameMath.minwp_user_idis stamped on the entry and dropped again when the response is built, exactly as §3 describesBroadcastChanneluse in its client codeAWARENESS_TIMEOUTat 30sFinding 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.