Gutenberg Sync Engines
In active development: A comparison of possible sync engines and transports for Gutenberg RTC
by WordPress Contributors · github.com/automattic/gutenberg-sync-engines · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/automattic/gutenberg-sync-engines/archive/refs/heads/trunk.zipPluggable real-time collaboration engines and transports for Gutenberg.
What it provides
Engines (how concurrent edits merge):
- intent-log: a server-authoritative log of typed intents; concurrent edits merge by transform, genuine conflicts are set aside for review, and no work is silently lost.
- yjs-server: a server-authoritative CRDT: the vendored y-php library merges every update into a canonical room document server-side, compacts by itself, and materializes post content.
- de-rtc: Distributed Editing's save-centric model: clients propose whole content against a named base version, the server three-way-merges every proposal. Genuine conflicts escalate instead of silently merging.
Transports (how updates move):
- http-polling: short-poll
POST /wp-sync/v1/updates(default). - http-long-polling: the same, held open until data is ready.
- websocket: push over a persistent socket served by a bundled PHP
daemon (
wp collaboration sync-server). For local dev,npm run rtc:wsstarts everything in one command (andnpm run rtc:httpswitches back).
Storage (where a room's updates live):
- Two plugin-owned tables,
{prefix}sync_updates(the update log) and{prefix}sync_room_meta(lineage, awareness, engine bookkeeping), substituted for Gutenberg's default post-meta storage. No collaboration write touches post caches. On a site with a persistent object cache (Redis, Memcached), who is present in a room is kept in the cache rather than the database, the storage strategy the WordPress hosting performance tests recommended; a poll that changes nothing writes nothing. Activating the plugin creates the tables; deactivating it leaves them and every room in place; deleting the plugin (uninstall.php) or runningwp collaboration storage dropremoves them.wp collaboration storage statusshows what a site has.
The active engine, and how editors get each other's changes (polling,
polling with an advisory channel over WebRTC or a WebSocket, long
polling, or WebSocket), are chosen on the plugin's Settings →
Collaboration screen (or via wp_sync_engine / the
WP_COLLABORATION_TRANSPORT config value).
Comparing the engines? Start with docs/. The short
answer and the full trade-off — scorecard, feature parity, resource shapes,
and each engine's known gaps — live in
docs/engine-comparison.md; the transports are
compared separately in docs/transports.md. Both are
deliberately number-free. Run npm run bench for a report of what the
plugin adds to a server on your own hardware, and
npm run bench -- --suite=engines for the full engine-decision numbers.
Architecture
Both axes are independent registries with a client/server handshake: the
server announces the active engine + transport, the client negotiates
against what it has registered, and any mismatch degrades to a post lock
rather than corruption. See Gutenberg's
prototypes/sync/ARCHITECTURE.md for the full picture.
The plugin registers via:
- PHP: the
wp_sync_enginesandwp_sync_transportsfilters. - JS:
registerSyncEngine/registerSyncTransport, unlocked from@wordpress/sync's private APIs.
Development
A modified copy of Gutenberg at runtime is vendored as a git subtree in
gutenberg/ and mounted by .wp-env.json so the local WordPress environment
runs the exact Gutenberg the engines were built against. No separate checkout
needed.
Setup
composer install # PHP tooling (PHPCS/WPCS, PHPUnit + polyfills)
npm install # JS tooling (@wordpress/scripts, wp-env, Playwright)
npm run build # Build this plugin's client bundle
# Build the vendored Gutenberg.
cd gutenberg && npm install --ignore-scripts && npm run build && cd ..
Environment
npm run env start # Start WordPress (Gutenberg subtree + this plugin)
npm run env stop # Stop it
Alternatively, try it using WordPress Playground. Note: On the official WordPress playground, every browser tab is its own WordPress site, so a second tab cannot join the first tab's editing session. Instead, use a local Playground instance:
npm run playground
Tests
npm run test:js # Jest — engines/providers + frozen-core vectors
npm run test:php # PHPUnit in the wp-env tests container (loads the
# Gutenberg subtree as the framework, then the plugin)
npm run test:e2e # Playwright — two-browser collaboration against the
# running env (needs `npx playwright install chromium`)
Benchmarks and tools
tests/benchmarks/— a server-side engine benchmark harness: it drives any registered engine through the production ingest/read seam and reports service-time percentiles, payload and storage growth, and (for intent-log) merge-quality metrics;compare.jsrenders multiple runs side by side. Seetests/benchmarks/README.mdfor how to run it and how to read the numbers.tests/benchmarks/transport/— a transport experience benchmark: two real browser clients measure edit-to-visible propagation latency and wire traffic (editing + idle) per transport. See its README.tests/tools/— Node CLI utilities: a long-running intent-log simulator sweep (node tests/tools/sweep.js), a manual two-tab sync observer against a live environment (node tests/tools/observe-two-tab-sync.mjs), and the frozen-core test-vector generators.
Testing by yourself
If you need to test behavior by yourself, you can open a separate browser and use this script in the console.
(async () => { const { subscribe, select } = wp.data; const clientId = await new Promise((resolve) => { const initial = select('core/block-editor').getSelectedBlockClientId(); if (initial) { resolve(initial); return; } const unsubscribe = subscribe(() => { const id = select('core/block-editor').getSelectedBlockClientId(); if (id) { unsubscribe(); resolve(id); } }); }); const doc = document.querySelector('iframe[name="editor-canvas"]')?.contentDocument ?? document; const blockEl = doc.querySelector(`[data-block="${clientId}"]`); const editable = blockEl?.querySelector('[contenteditable="true"]') ?? blockEl; if (!editable) { console.warn('No editable element found for block', clientId); return; } editable.focus(); const sel = doc.defaultView.getSelection(); if (!sel.rangeCount || !editable.contains(sel.anchorNode)) { const r = doc.createRange(); r.selectNodeContents(editable); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); } let i = 0; const intervalId = setInterval(() => { const char = String(i % 10); const keyInit = { key: char, code: `Digit${char}`, keyCode: 48 + Number(char), which: 48 + Number(char), bubbles: true, cancelable: true }; editable.dispatchEvent(new KeyboardEvent('keydown', keyInit)); const notCancelled = editable.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true, cancelable: true })); if (notCancelled) { const s = doc.defaultView.getSelection(); if (s.rangeCount) { const r = s.getRangeAt(0); r.deleteContents(); const t = doc.createTextNode(char); r.insertNode(t); r.setStartAfter(t); r.setEndAfter(t); s.removeAllRanges(); s.addRange(r); } editable.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true })); } editable.dispatchEvent(new KeyboardEvent('keyup', keyInit)); i++; }, 60); window.__stopTyping = () => { clearInterval(intervalId); console.log('Stopped.'); }; console.log('Typing started on block', clientId, '— run window.__stopTyping() to stop.'); })();
It will keep typing and let you test different scenarios. You can stop it by entering window.__stopTyping() in the same console you ran the original command.