Spindle vs the field

A linearized Matrix homeserver: an append-only log per room, materialized state, and no state resolution on the hot path. Every milestone, the same client operations are measured against Synapse and both Rust siblings — wins, noise and losses all published from the committed raw numbers.

0cells faster
0within noise
0slower — investigated

Why the shape of the storage shows up in every row

Watch the same six events arrive at both designs, then watch one read ask "what is the state?" The loop below plays the whole difference: on one side the answer must be computed; on the other it was already written down.

A conventional homeserver

a fork — normal read: "what is the state?" resolving branches… state ready

Rooms are a directed graph of events. Forks are normal, so answering "what is the state?" means running state resolution over the branches — work that grows with the room and sits on the hot path of sync, send, and join.

Spindle

ordered at the door state root read: "what is the state?" state ready one content-addressed read

Each room is an append-only log with materialized state: every entry carries the content address (BLAKE3 hash trie) of the state after it. "The state now" is one read; history is a range; nothing resolves on the hot path. Federation forks are collapsed at the door, bounded, and never taxed on reads.

append-only log · Kafka, Raft content-addressed state · git, Merkle trees HAMT snapshots · Clojure, CHAMP single-writer order · LMAX, TigerBeetle losses published · every red cell links its investigation

That is the bet these pages test. The comparisons below measure the same client operations against the same workloads on Synapse (the reference implementation) and on Continuwuity and Tuwunel (the two Rust siblings, both descended from Conduit) — and when a cell goes the wrong way, the roadmap's rule is that it gets investigated, not explained away.

Latest sitting — m7-progress

continuwuity (m7-progress.continuwuity.r1.json) · continuwuity (m7-progress.continuwuity.r2.json) · continuwuity (m7-progress.continuwuity.r3.json) · spindle (m7-progress.spindle.r1.json) · spindle (m7-progress.spindle.r2.json) · spindle (m7-progress.spindle.r3.json) · synapse (m7-progress.synapse.r1.json) · synapse (m7-progress.synapse.r2.json) · synapse (m7-progress.synapse.r3.json) · tuwunel (m7-progress.tuwunel.r1.json) · tuwunel (m7-progress.tuwunel.r2.json) · tuwunel (m7-progress.tuwunel.r3.json)

spindle spindle spindle continuwuity continuwuity continuwuity synapse synapse synapse tuwunel tuwunel tuwunel — hover a server to isolate its line in every chart

Event context, deep0.03.77.5ms2008003,200events in roomcontinuwuity: 5.80 mscontinuwuity: 6.94 mscontinuwuity: 3.91 mssynapse: 4.42 mssynapse: 4.39 mssynapse: 4.54 mstuwunel: 1.60 mstuwunel: 1.84 mstuwunel: 1.80 msspindle: 0.74 msspindle: 0.78 msspindle: 0.91 msSpindle
The /context of an event far back in history. On a linear log an old event's neighbourhood is an index range, not a graph walk.
Join a room0.022.444.8ms2008003,200events in roomcontinuwuity: 5.16 mscontinuwuity: 4.88 mscontinuwuity: 6.12 mssynapse: 41.45 mssynapse: 38.49 mssynapse: 40.67 mstuwunel: 4.82 mstuwunel: 4.84 mstuwunel: 4.52 msspindle: 1.03 msspindle: 1.07 msspindle: 1.07 msSpindle
A local user joins: one membership event through the same append path as any other, authorized against materialized state.
Paginate history0.03.87.5ms2008003,200events in roomcontinuwuity: 3.74 mscontinuwuity: 4.25 mscontinuwuity: 4.93 mssynapse: 6.63 mssynapse: 6.75 mssynapse: 6.99 mstuwunel: 1.68 mstuwunel: 2.12 mstuwunel: 2.13 msspindle: 0.84 msspindle: 0.76 msspindle: 0.79 msSpindle
A /messages page walking backwards. The linear log makes this a bounded range read — no topological sort at request time.
Send a message0.013.426.7ms2008003,200events in roomcontinuwuity: 1.37 mscontinuwuity: 1.85 mscontinuwuity: 3.56 mssynapse: 24.74 mssynapse: 24.00 mssynapse: 22.67 mstuwunel: 1.41 mstuwunel: 1.87 mstuwunel: 2.51 msspindle: 1.10 msspindle: 0.99 msspindle: 0.99 msSpindle
One event appended to a room. Spindle assigns the next index in the room's linear log and folds one state snapshot forward; a DAG server must pick extremities, and its cost tends to grow with the room.
Sliding sync (MSC4186)0.04.69.2ms2008003,200events in roomcontinuwuity: 0.78 mscontinuwuity: 0.78 mscontinuwuity: 0.84 mssynapse: 7.52 mssynapse: 7.72 mssynapse: 8.56 mstuwunel: 1.13 mstuwunel: 1.20 mstuwunel: 1.19 msspindle: 0.54 msspindle: 0.65 msspindle: 0.58 msSpindle
The request Element X makes where classic clients call /sync: a sorted room-list window plus per-room state. The room-list question is one point read per room on Spindle's storage.
Read room state0.01.12.1ms2008003,200events in roomcontinuwuity: 0.60 mscontinuwuity: 0.67 mscontinuwuity: 0.87 mssynapse: 1.96 mssynapse: 1.97 mssynapse: 1.94 mstuwunel: 0.63 mstuwunel: 0.65 mstuwunel: 0.70 msspindle: 0.45 msspindle: 0.39 msspindle: 0.41 msSpindle
The full current state of a room. The log's head entry carries the state root; answering is one rehydration of a hash trie.
Incremental /sync, one event0.05.110.1ms2008003,200events in roomcontinuwuity: 0.91 mscontinuwuity: 0.87 mscontinuwuity: 0.96 mssynapse: 9.37 mssynapse: 8.83 mssynapse: 8.58 mstuwunel: 1.05 mstuwunel: 1.06 mstuwunel: 0.96 msspindle: 0.83 msspindle: 0.83 msspindle: 0.85 msSpindle
The same request when a room does have something waiting. It takes a different path from the empty poll -- the server skips rooms with no new events entirely -- so the two are measured separately rather than averaged into one misleading number.
Initial /sync0.01.83.5ms2008003,200events in roomcontinuwuity: 1.98 mscontinuwuity: 2.07 mscontinuwuity: 2.23 mssynapse: 3.25 mssynapse: 3.16 mssynapse: 2.85 mstuwunel: 1.78 mstuwunel: 1.84 mstuwunel: 1.96 msspindle: 1.29 msspindle: 1.20 msspindle: 1.30 msSpindle
A client's first sync: full state plus recent timeline for every room. Materialized state makes 'the state now' one content-addressed read instead of a resolution.
Incremental /sync, nothing new0.01.73.4ms2008003,200events in roomcontinuwuity: 2.01 mscontinuwuity: 1.99 mscontinuwuity: 2.10 mssynapse: 3.12 mssynapse: 2.99 mssynapse: 2.89 mstuwunel: 0.82 mstuwunel: 0.85 mstuwunel: 0.87 msspindle: 0.68 msspindle: 0.67 msspindle: 0.79 msSpindle
The request a running client makes over and over: since=<token>, and the answer is usually “nothing”. It is the most common request a homeserver ever serves, and until M5 no sitting measured it at all.

Every cell

operation vs continuwuity vs synapse vs tuwunel
200 800 3,200 200 800 3,200 200 800 3,200
Event context, deep context_deep 7.80× 8.93× 4.30× 5.95× 5.64× 5.00× 2.16× 2.37× 1.98×
Join a room join 5.03× 4.56× 5.72× 40.42× 35.93× 38.00× 4.70× 4.52× 4.22×
Paginate history messages_page 4.46× 5.58× 6.26× 7.91× 8.85× 8.88× 2.00× 2.78× 2.71×
Send a message send 1.25× 1.86× 3.59× 22.51× 24.15× 22.88× 1.28× 1.88× 2.53×
Sliding sync (MSC4186) sliding_window 1.46× 1.21× 1.46× 14.01× 11.88× 14.82× 2.10× 1.85× 2.06×
Read room state state 1.31× 1.71× 2.16× 4.32× 5.05× 4.77× 1.39× 1.67× 1.73×
Incremental /sync, one event sync_delta 1.10× 1.05× 1.12× 11.28× 10.65× 10.05× 1.26× 1.27× 1.12×
Initial /sync sync_initial 1.54× 1.73× 1.71× 2.53× 2.64× 2.19× 1.39× 1.53× 1.50×
Incremental /sync, nothing new sync_poll 2.94× 2.96× 2.67× 4.56× 4.44× 3.68× 1.20× 1.27×  1.11×

Measured over 3 rounds per server. A cell is called only when the two servers' rounds separate: win our slowest round beat their fastest · overlapping the ranges cross, so the difference is not resolved by this many rounds, whatever the medians say · loss our fastest round lost to their slowest — every such cell links to its investigation, because the roadmap treats it as a defect until explained. Hover any cell for the median and the observed spread.

That rule bounds the false-call rate for one cell, and this table has 81. Two identical servers separate by luck 10.0% of the time at 3 rounds, so about 8.1 of these cells should be called by chance alone — against 75 actually called. The arithmetic cannot say which ones, so a call marked is one that stands alone: the same operation is not called the same way at any other size. A cost that is real in a per-item measure normally shows across the size axis, so an isolated call is the shape a chance separation takes. Read those as unconfirmed rather than as results (#183).

Earlier sittings

The trail matters: each milestone re-measures the same operations, so a regression shows up as a cell that changed color between sittings.

m4-final — 44 faster · 19 within noise · 0 slower
operation vs continuwuity-26.8.1 vs synapse-1.159.0 vs tuwunel-1.9.0
200 800 3,200 200 800 3,200 200 800 3,200
Event context, deep context_deep 6.54× 5.33× 6.56× 6.60× 4.29× 4.22× 2.26× 1.72× 1.59×
Join a room join 3.09× 2.94× 3.00× 27.04× 25.73× 26.60× 2.72× 2.48× 2.60×
Paginate history messages_page 3.07× 3.15× 3.56× 7.16× 6.39× 5.67× 1.75× 1.63× 1.57×
Send a message send 1.18× 1.25× 1.97× 20.03× 15.11× 14.28× 1.20× 1.16× 1.50×
Sliding sync (MSC4186) sliding_window 0.81× 1.05× 0.90× 6.17× 7.38× 7.73× 1.11× 1.33× 1.17×
Read room state state 1.33× 1.41× 1.61× 4.33× 3.49× 3.91× 1.56× 1.18× 1.31×
Initial /sync sync_initial 1.19× 1.27× 1.25× 1.89× 2.12× 1.84× 0.94× 1.15× 1.05×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

m3-final — 51 faster · 12 within noise · 0 slower
operation vs continuwuity-26.8.1 vs synapse-1.159.0 vs tuwunel-1.9.0
200 800 3,200 200 800 3,200 200 800 3,200
Event context, deep context_deep 7.42× 10.39× 2.61× 6.44× 6.96× 5.40× 1.81× 2.09× 1.70×
Join a room join 7.51× 6.78× 4.60× 47.51× 40.27× 35.75× 4.12× 3.97× 3.34×
Paginate history messages_page 5.05× 5.85× 4.22× 10.28× 8.08× 7.56× 2.20× 2.34× 1.94×
Send a message send 1.95× 2.78× 4.02× 26.53× 27.29× 25.20× 0.98× 1.67× 2.01×
Sliding sync (MSC4186) sliding_window 1.07× 0.94× 0.77× 9.49× 10.49× 9.37× 1.35× 1.42× 1.27×
Read room state state 1.78× 1.83× 1.79× 4.89× 5.52× 4.46× 1.09× 1.85× 1.10×
Initial /sync sync_initial 1.72× 1.53× 1.31× 1.49× 2.00× 1.50× 0.89× 0.98× 1.00×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

Nothing in this sitting reads as a loss under the band above. The investigations below were written when it did, and are kept because they are the evidence for the conclusion, not a footnote to it.

Investigated — Sliding sync (MSC4186): 0.77× vs Continuwuity at 3,200 events, and 0.85× in the same day's discarded loaded run — repeatable by the two-sitting rule, so it got the full second look. Both servers were probed live minutes after the sitting, same client, same instant, two shapes: like-for-like the gap does not exist (creator shape 0.844 ms vs 0.854 ms, parity; the driver's exact observer shape 0.785 ms vs 0.874 ms, Spindle 1.11× faster). The two sitting legs caught opposite sides of the machine's same-day swing. No fix ships because no defect was found; the cell keeps its measured value and links here — the honest kind of red.

Investigated — Initial /sync: 0.89× vs Tuwunel at 200 events in this sitting — and 1.21× in the same day's discarded run, with 800 and 3,200 in noise both times. A cell that flips sign between sittings hours apart is run-to-run variance, published as measured.

m3-progress — 53 faster · 10 within noise · 0 slower
operation vs continuwuity-26.8.1 vs synapse-1.159.0 vs tuwunel-1.9.0
200 800 3,200 200 800 3,200 200 800 3,200
Event context, deep context_deep 4.94× 2.82× 6.01× 5.06× 5.04× 5.28× 1.59× 1.99× 1.89×
Join a room join 3.43× 4.83× 4.07× 31.27× 34.83× 26.96× 2.51× 3.08× 2.23×
Paginate history messages_page 3.82× 5.52× 4.05× 7.79× 8.23× 7.00× 1.73× 2.29× 1.81×
Send a message send 1.63× 2.06× 4.07× 21.42× 20.66× 21.32× 1.00× 1.51× 2.00×
Sliding sync (MSC4186) sliding_window 1.26× 0.90× 0.88× 8.32× 6.84× 7.00× 1.42× 1.39× 1.11×
Read room state state 1.63× 1.60× 1.50× 4.34× 4.21× 3.75× 0.93× 1.67× 1.20×
Initial /sync sync_initial 1.79× 1.50× 1.89× 2.08× 1.98× 1.97× 1.03× 1.14× 1.05×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

Nothing in this sitting reads as a loss under the band above. The investigations below were written when it did, and are kept because they are the evidence for the conclusion, not a footnote to it.

Investigated — Sliding sync (MSC4186): 0.90× and 0.88× vs Continuwuity at 800 and 3,200 events — and the same cell sat at 0.83× in the M2 close-out, so two sittings agreed this was repeatable, not noise. A component probe on the live bench server found the room list's recency sort reading each room's head event body from the store and parsing its JSON on every request, for one i64. #126 moved the sort key into memory, refreshed by the append that changes it; re-measured on the same idle machine the cells recover to 0.96× and 1.00×, and Spindle's own growth curve flattens from 1.28× to 1.13× across a 16× room-size increase.

m2-final — 51 faster · 12 within noise · 0 slower
operation vs continuwuity-26.8.1 vs synapse-1.159.0 vs tuwunel-1.9.0
200 800 3,200 200 800 3,200 200 800 3,200
Event context, deep context_deep 8.21× 4.08× 3.89× 5.29× 6.12× 4.28× 1.57× 2.19× 1.53×
Join a room join 4.84× 5.42× 4.21× 30.82× 30.35× 26.68× 3.16× 3.10× 3.05×
Paginate history messages_page 3.90× 4.54× 4.08× 8.28× 7.41× 6.99× 1.97× 2.18× 1.86×
Send a message send 1.82× 1.89× 3.41× 21.22× 17.01× 19.78× 1.30× 1.38× 2.32×
Sliding sync (MSC4186) sliding_window 0.99× 1.34× 0.94× 7.90× 9.64× 6.76× 1.45× 1.85× 1.21×
Read room state state 1.14× 1.56× 2.22× 3.72× 3.94× 4.91× 0.85× 1.13× 1.64×
Initial /sync sync_initial 1.74× 1.59× 1.50× 2.09× 1.87× 1.69× 1.18× 1.17× 1.09×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

Nothing in this sitting reads as a loss under the band above. The investigations below were written when it did, and are kept because they are the evidence for the conclusion, not a footnote to it.

Investigated — Sliding sync (MSC4186): 0.87× vs Continuwuity at 3,200 events was the one real loss of the M2 close-out, and the only curve growing with room size. Bisecting a live server pinned it in one probe: the unread counter was reading every event body after the receipt floor. #113 replaced that walk with two binary searches over a per-room sender index — 11.79 ms → 1.00 ms on the pathological case — and the M3 rows below show the cell recovered.

Investigated — Read room state: 0.85× vs Tuwunel at 200 events, and 0.93× again at M3 progress — the one cell Tuwunel held across two sittings, so the investigation started in their tree. Their /state serves each event through RocksDB's block cache; ours paid a room-lock acquisition, a body read and a JSON parse per state event, per request. Component probes showed their state machinery was never actually faster — their per-request pipeline is just leaner. #129 caches the rendered /state body under its BLAKE3 state root (content-addressed, so a hit is provably current and a root mismatch is the only invalidation); re-measured against the live Tuwunel binary the cell flips to 1.91× and 1.33× in Spindle's favour.

m2-progress — 36 faster · 6 within noise · 0 slower
operation vs continuwuity-26.8.1 vs synapse-1.159.0
200 800 3,200 200 800 3,200
Event context, deep context_deep 3.69× 1.87× 3.58× 5.35× 3.93× 4.52×
Join a room join 3.66× 3.60× 4.30× 29.11× 29.05× 31.06×
Paginate history messages_page 2.35× 2.00× 2.28× 6.89× 6.53× 5.53×
Send a message send 1.36× 1.66× 3.28× 18.97× 14.12× 16.16×
Sliding sync (MSC4186) sliding_window 1.12× 1.14× 1.26× 7.98× 8.30× 8.25×
Read room state state 1.26× 1.16× 1.48× 4.08× 3.07× 5.10×
Initial /sync sync_initial 1.60× 1.56× 1.62× 2.18× 1.91× 1.93×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

m1-spindle-vs-synapse — 15 faster · 0 within noise · 0 slower
operation vs synapse-1.159.0
100 400 1,600
Join a room join 27.75× 28.84× 30.32×
Paginate history messages_page 5.21× 5.03× 4.97×
Send a message send 17.41× 20.17× 16.59×
Read room state state 4.00× 4.86× 3.35×
Initial /sync sync_initial 2.08× 2.08× 1.92×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

m1-spindle-vs-continuwuity — 15 faster · 3 within noise · 0 slower
operation vs continuwuity-26.8.1
200 800 3,200
Event context, deep context_deep 3.85× 2.57× 2.98×
Join a room join 2.89× 3.78× 5.04×
Paginate history messages_page 2.19× 2.03× 2.98×
Send a message send 1.40× 2.20× 3.36×
Read room state state 1.17× 1.68× 1.52×
Initial /sync sync_initial 1.38× 0.99× 1.21×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

concurrency-perroom — 0 faster · 0 within noise · 0 slower
operation
send_throughput/200 send_throughput/200

2 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

concurrency — 8 faster · 0 within noise · 0 slower
operation vs synapse vs synapse-postgres
1 2 4 8 1 2 4 8
send_throughput/200 send_throughput/200 24.81× 36.78× 40.87× 35.16× 40.51× 46.70× 59.29× 50.95×

1 round(s) per server — not resolved. Three rounds a side is the minimum that means anything: if two servers were identical, all of one side's rounds landing below all of the other's happens by chance 2/C(2n, n) of the time — one in three at two rounds, one in ten at three. With no spread to read, the cells below are coloured by the repeatability this host was measured to have: six rounds of the same binary moved the median cell 1.38×, and 21 of 21 cells varied by more than the ±10% band this page used to use. So: ≥1.38× · 0.72–1.38× · ≤0.72×. A grey cell is not a tie — it is a difference this sitting cannot see, and only more rounds can (#171). The large ratios are unaffected. Hover any cell for the raw milliseconds.

Method

Versions measured, ports, registration quirks and the full narrative per sitting: docs/benchmarks.md.