Performance
How long does a list take to update? Measured examples, simple graphs, and commands to try it yourself.
Imagine a chess library with 100,000 games. You show 50 games at a time, scroll through the library, rename players, and import more games. This page shows how long those actions took in our benchmark.
“Ready” means the server’s published list passed our data checks. Downloading and drawing the list in a browser were outside the test.
WhitePawn benchmark
Here are the results in everyday terms:
- Open a 50-game list: typically 0.17–0.38 seconds in a 100,000-game library.
- Change a game or player: typically 0.06–0.15 seconds until the published result reflected the change.
- Import 30,000 games: 30 seconds to write them, then 8–12 seconds to check that the retained lists had caught up.
- Start with 100,000 games already seeded: about 9–16 seconds until the scheduler and SSP were ready.
The two 100,000-game runs use the same dataset. We call them 100K A and 100K B so you can see how results varied. A ran in the standard profile; B repeated the case in the large profile. They ran on a shared development machine, and their samples are kept separate.
These are October 11, 2026 measurements of canary.301, before the newer index-building and batching improvements. They are a recorded baseline; we have not rerun this workload to measure those improvements yet.
Open a list of 50 games
Example: “There are 100,000 games in my library. I scroll to a new page of 50 games. The server publishes that page in about 0.17 seconds in run A, or 0.38 seconds in run B.”
The graph also shows two smaller tests. 10K + moves stores synthetic chess move text as well as game metadata; the list itself still requests only the small summary and player names.
p50 is the middle result: half the measurements finished within that time. p95 is the time within which 95% finished. A shorter bar means less waiting. All values below are in seconds.
10K games
- p50
- 0.06 s
- p95
- 0.08 s
10K + moves
- p50
- 0.05 s
- p95
- 0.08 s
100K A
- p50
- 0.17 s
- p95
- 0.28 s
100K B
- p50
- 0.38 s
- p95
- 0.60 s
Each case scrolled 100 times, with at most twenty lists retained at once. There were 99 new registrations and one visit to an already retained list. These times start before registration and include the response and publication check; they do not stop at the first HTTP response.
Change a game or rename a player
Example: “I rename a player in a 100,000-game library. The server’s list is ready with the updated player record in about 0.05 seconds in run A, or 0.14 seconds in run B.”
Other changes take different amounts of work. These are the typical times (p50) for three concrete actions:
| Action | 100K A | 100K B |
|---|---|---|
| Rename a player | 0.05 s | 0.14 s |
| Move out of a collection | 0.14 s | 0.15 s |
| Delete a visible game | 0.14 s | 0.15 s |
The next graph combines all seven kinds of change: insert, edit, reorder, move between collections, delete, rename a player, and write to another collection. Each full run has 140 measurements, taken one change at a time.
100K A
- p50
- 0.06 s
- p95
- 0.15 s
100K B
- p50
- 0.15 s
- p95
- 0.21 s
Saving to the database finishes earlier. For example, run A’s typical database acknowledgement was 0.003 seconds, while the published result check took 0.063 seconds. The latter already includes the write time.
Import 30,000 games
Example: “I start with 100,000 games and import 30,000 more at 1,000 games per second. The database accepts all 30,000 in 30 seconds. After the import ends, the benchmark needs about 12 more seconds in run A, or 8 more seconds in run B, to check the source and confirm the retained lists have caught up.”
That extra time has two parts. First the benchmark reads the database to work out what each list should contain. Then it waits for the published lists to match. Sync continues during both parts.
100K A
- Source check
- 4.28 s
- Remaining wait
- 7.48 s
100K B
- Source check
- 2.66 s
- Remaining wait
- 5.47 s
Add the two bars for each run to get the roughly 12- or 8-second confirmation time. The remaining-wait bar alone does not measure the whole delay after the import. This checks the retained lists, rather than every possible query.
| Test | Time | Games added | Games/second |
|---|---|---|---|
| Steady | 60 s | 3,000 | 50 |
| Import | 30 s | 30,000 | About 1,000 |
Both 100K runs had zero failed writes in these phases. Imports used 100-game transactions, with at most four transactions running at once. The achieved rate measures database commits; it does not mean every list updates 1,000 times per second. Across these completed runs, the publication queue’s sampled peak was about 12 MiB. Full queue measurements are in the detailed results below.
Start with an existing library
Example: “My 100,000 games are already in the database. Starting the scheduler and SSP takes about 9 seconds in run A or 16 seconds in run B before the backend is ready.”
The scheduler reads and distributes changes. The SSP maintains the live query results. This graph includes starting both services and waiting for an available SSP.
100K A
- Backend ready
- 9.32 s
100K B
- Backend ready
- 15.72 s
These are single startup observations. Database startup and creating the test data are separate: seeding the 100,000 games took about 15 seconds in A and 22 seconds in B.
How much memory did it use?
Example: “During the 100,000-game test, the scheduler peaked at about 1.1 GiB and the SSP at about 0.76 GiB. The database container used another 1.2–1.3 GiB at its sampled peak.”
100K A
- Scheduler
- 1.13 GiB
- SSP
- 0.76 GiB
100K B
- Scheduler
- 1.07 GiB
- SSP
- 0.75 GiB
The graph covers the scheduler and SSP; the database is additional. These are separate peaks across the whole test and may have occurred at different times. The database and replica files occupied about 0.92 GiB on disk after each 100K run. CPU, database memory and detailed storage figures are available below.
Even a 50-row list needs more memory than its fifty displayed rows. The SSP also holds source records, indexes and the state used to maintain the query.
What about one million or ten million games?
We do not have measured results for those sizes. The benchmark’s resource checks stopped them before startup on this machine.
- 10,000 games: completed with and without move text.
- 100,000 games: completed twice.
- 1 million games: resource-limited, with and without move text; not run.
- 10 million games: resource-limited; not run.
The machine had 36 GiB RAM and a 15.66 GiB Docker memory budget. The checks keep memory and disk headroom, using smaller completed tests to estimate larger ones. A resource-limited result is a capacity estimate, rather than an observed crash. The standard and large commands therefore exited nonzero, while retaining their successful 100K results.
Make a large library easier to query
Example: “Show the next 50 games from this collection, in their saved order.” An index on the collection and sort field lets the engine look up the matching range. Think of finding a chapter in a book’s index instead of reading every page from the beginning.
Here database means the game collection. Bind $database to its
game_database record. LIMIT 50 returns fifty rows; START 100 skips the
first hundred. The final id makes tied sort values deterministic.
Newer releases build a first-use index in short steps so writes can run between steps. They also group already queued changes into batches. These changes can reduce repeated work, but this page’s WhitePawn measurements predate them. There is no measured WhitePawn speedup to quote yet.
More about indexes, batching and engine-only measurements
An indexed window needs equality filters on the index’s leading fields, followed by matching sort fields. An unrelated filter or a range comparison on its own does not get the same shortcut. Full-text, vector and count indexes are not used for this list lookup.
A historical Rust engine experiment registered twenty windows over one million rows in 14.6 seconds without the index and 0.58 seconds with it, including the first build. The fixture has 420,000 rows in the selected collection. This measured in-process engine registration; it did not include a database changefeed, publication or a browser.
Indexes are built when needed, kept for an hour after their last view leaves, and prewarmed for restored active views before the SSP reports ready. Schema changes prewarm replacements too. A live first build still scans its table, but targets 25 ms per step and releases the circuit between steps. Writes keep an unfinished index correct; queries only read it once complete. A separate one-million-row test reduced the longest lock hold from 0.88 seconds to 74 ms, while the complete build took about twice as long. These are engine observations, not the WhitePawn publication times above.
The scheduler batches already queued ordinary changes up to 128 records or 1 MiB, without waiting to fill a batch. The SSP applies their source changes in order and updates affected query computations once per batch. Older SSPs, replay, jobs, users and internal lifecycle records keep their single-record paths.
For implementation details, see registration and ingest stages
and batch ingestion. Rust embedders can
use prewarm_query_indexes, prewarm_active_indexes, prewarm_index and the
step-based variants. Their IndexBuildStats report index count, rows indexed
and build time. Query stage timings are shared measurements for affected views,
rather than exclusive CPU costs for each view.
Slow-operation evidence, publication wait times and queue diagnostics are explained in the SSP API and scheduler API. Permission-join maintenance still costs work proportional to the set of records a changed join key affects.
Run it yourself
From the repository root, with Docker running:
The smoke test runs both 10K datasets and short write phases. Standard attempts 100K and 1M cases. Large also attempts 10M. Each case gets a fresh, disposable, disk-backed SurrealDB 3.1.5 instance and one scheduler/SSP pair.
These commands save summary.md, samples.json and run.json under
results/whitepawn-<timestamp>/ in the repository root. Completed results survive a later
failure. See the benchmark CLI reference
for custom binaries, output paths and the 3,600-second per-wait startup/readiness
timeout.
How to read these results
The WhitePawn tests ran on October 11, 2026 with an Apple M3 Max, 14 cores,
36 GiB RAM, macOS arm64, Node 22.22.3 and Docker 29.4.0. Other development
containers were running. The measured release binaries were version
0.0.1-canary.301; SurrealDB was 3.1.5.
The data is synthetic: twenty collections, 256 players and 80% of games in one large collection. Games share sort values, and each list joins the white and black player records. The full-move fixture adds about 1.2 KiB of text per game.
Publication checks verify the expected game/player references, parent links and
required record versions. They do not compare every projected field or prove
published edge order. Initial ready status alone is insufficient. Browser
rendering, client downloads, production authorization, background jobs and
multiple SSPs are outside these tests.
Exact numbers, sample counts, CPU, queues and report sources
Dataset and startup
“Standard” and “large repeat” below identify two separate runs of the same 100K compact case. Their samples are not pooled. Both larger profiles remained incomplete because later tiers failed capacity preflight; the completed 100K cases passed final membership and version verification.
| Case | Initial games | Serialized JSON bytes | Version metadata records | Result |
|---|---|---|---|---|
| 10K games | 10,000 | 3,208,416 | 10,276 | Passed |
| 10K + moves | 10,000 | 15,201,465 | 10,276 | Passed |
| 100K A | 100,000 | 31,897,150 | 100,276 | Passed |
| 100K B | 100,000 | 31,897,150 | 100,276 | Passed |
Serialized bytes sum the actual JSON fixture records, including players and
collections; they are separate from database storage on disk. _00_version
metadata also covers players and collections and is counted before mutations.
The PGN payload averages about 1.2 KiB per game.
Startup durations are single observations in seconds. Database readiness precedes seeding. Backend readiness starts after seeding and includes scheduler/SSP startup and the wait for an available SSP; it is separate from database and seed time. The individual process readiness measurements are included within that backend interval.
| Case | Database ready | Seed | Scheduler ready | SSP ready | Backend ready |
|---|---|---|---|---|---|
| 10K games | 2.19 | 1.71 | 0.77 | 0.26 | 2.54 |
| 10K + moves | 0.92 | 2.00 | 0.76 | 0.26 | 2.03 |
| 100K A | 1.26 | 15.48 | 6.29 | 1.01 | 9.32 |
| 100K B | 1.42 | 22.17 | 12.42 | 1.79 | 15.72 |
Window registration and mutation latency
All latency values are p50 / p95 / p99 in milliseconds, using nearest-rank percentiles. Registration response ends at the scheduler’s HTTP response. Published readiness starts before that request and ends when committed root and child membership matches and record versions meet the required versions. It includes response time. Polls run every 10 ms plus database request time.
Each case has 99 new scroll registrations and one retained-window hit across 100 steps; the separate warmup is excluded here.
| Case | New registrations | Registration response | Published readiness |
|---|---|---|---|
| 10K games | 99 | 15.73 / 21.59 / 26.38 | 55.56 / 83.68 / 203.91 |
| 10K + moves | 99 | 15.67 / 22.48 / 91.65 | 54.71 / 81.60 / 110.38 |
| 100K A | 99 | 149.12 / 166.40 / 242.86 | 165.33 / 276.59 / 352.62 |
| 100K B | 99 | 257.62 / 464.33 / 679.90 | 377.36 / 600.39 / 799.54 |
Mutation probes run individually after the previous mutation has settled. Write acknowledgement ends at SurrealDB’s transaction response. Published readiness starts before the write and includes that acknowledgement. Source queries used to construct expected results run outside the timed interval.
| Case | Probes | Database acknowledgement | Published readiness |
|---|---|---|---|
| 10K games | 14 | 2.32 / 6.08 / 6.08 | 61.07 / 77.65 / 77.65 |
| 10K + moves | 14 | 2.29 / 5.46 / 5.46 | 58.96 / 79.62 / 79.62 |
| 100K A | 140 | 3.09 / 5.45 / 7.84 | 62.91 / 146.99 / 150.40 |
| 100K B | 140 | 4.71 / 12.61 / 35.08 | 145.04 / 214.51 / 285.02 |
The full cases contain twenty probes per category; smoke contains two. No registration or mutation samples failed in these completed cases. The category breakdown below shows published readiness for the two 100K cases. With only fourteen smoke probes, both p95 and p99 select the maximum observed value; these short runs provide limited evidence about tail latency.
| Mutation category | Standard: p50 / p95 / p99 ms | Large repeat: p50 / p95 / p99 ms |
|---|---|---|
| Insert entering a window | 62.91 / 69.08 / 69.15 | 151.71 / 232.00 / 243.30 |
| Projected-field update | 49.95 / 55.37 / 59.21 | 130.74 / 205.31 / 209.14 |
| Sort-boundary change | 137.88 / 148.16 / 148.30 | 160.11 / 214.90 / 218.15 |
| Collection-filter change | 140.88 / 146.09 / 147.34 | 146.73 / 229.12 / 285.02 |
| Visible-row delete | 139.31 / 150.40 / 151.56 | 154.38 / 192.36 / 197.92 |
| Joined player-name update | 47.86 / 59.48 / 59.50 | 135.42 / 211.20 / 319.46 |
| Unrelated collection write | 41.51 / 55.12 / 57.17 | 124.77 / 195.73 / 209.18 |
Unrelated collection writes measure publication to a separate one-row query, then verify that the main 50-row window remains unchanged. The unchanged-window check is outside the reported latency.
Write rate and catch-up
Full cases write 3,000 rows at a target of 50 rows/s for 60 seconds, then import 30,000 rows at a target of 1,000 rows/s for 30 seconds. Imports use 100-row transactions with at most four in flight. Smoke runs each phase for two seconds, committing 100 and 2,000 rows respectively. All completed cases had zero failed load-phase rows.
Throughput counts committed source rows per write interval. It does not establish the rate at which the SSP publishes them. After writes finish, the driver reads expected source windows, then times residual publication catch-up. The pipeline can drain while those source queries run, so the two durations below must be read together; residual catch-up alone is not total propagation delay.
| Case | Steady rows/s | Import rows/s | Steady source check / residual catch-up (s) | Import source check / residual catch-up (s) |
|---|---|---|---|---|
| 10K games | 49.97 | 999.52 | 0.68 / 0.05 | 0.73 / 24.38 |
| 10K + moves | 50.01 | 999.45 | 0.75 / 0.05 | 0.77 / 24.88 |
| 100K A | 50.00 | 999.98 | 4.39 / 0.16 | 4.28 / 7.48 |
| 100K B | 50.00 | 999.98 | 2.82 / 1.65 | 2.66 / 5.47 |
Write acknowledgement under load is shown separately below, in milliseconds. Steady samples each commit one row; import samples each commit 100 rows.
| Case | Steady transactions | Steady ACK: p50 / p95 / p99 | Import transactions | Import ACK: p50 / p95 / p99 |
|---|---|---|---|---|
| 10K games | 100 | 2.70 / 4.50 / 5.14 | 20 | 18.76 / 34.10 / 35.12 |
| 10K + moves | 100 | 3.05 / 6.50 / 11.25 | 20 | 23.49 / 30.42 / 38.51 |
| 100K A | 3,000 | 3.37 / 11.04 / 22.37 | 300 | 35.31 / 93.42 / 240.20 |
| 100K B | 3,000 | 5.67 / 23.43 / 48.58 | 300 | 24.02 / 35.40 / 44.09 |
Memory, CPU and storage
Scheduler/SSP process samples run every 250 ms; database container samples run
roughly every five seconds plus request time. These are observed peaks over the
whole case. CPU is the OS ps or Docker percentage: 100% represents one core,
and a process can exceed it. Mean CPU averages those observations and is not a
per-request CPU cost.
| Case | Process | Samples | Peak RSS / container memory | Mean / peak CPU % |
|---|---|---|---|---|
| 10K games | Scheduler | 183 | 406.77 MiB | 5.69 / 96.00 |
| 10K games | SSP | 180 | 193.19 MiB | 12.12 / 87.60 |
| 10K games | Database | 10 | 646.70 MiB | 87.44 / 110.64 |
| 10K + moves | Scheduler | 183 | 594.86 MiB | 5.03 / 87.60 |
| 10K + moves | SSP | 180 | 199.53 MiB | 12.42 / 85.30 |
| 10K + moves | Database | 10 | 771.70 MiB | 85.23 / 107.02 |
| 100K A | Scheduler | 730 | 1.13 GiB | 9.39 / 118.70 |
| 100K A | SSP | 706 | 774.78 MiB | 33.80 / 100.00 |
| 100K A | Database | 43 | 1.24 GiB | 70.80 / 138.35 |
| 100K B | Scheduler | 823 | 1.07 GiB | 7.94 / 118.00 |
| 100K B | SSP | 777 | 771.84 MiB | 29.46 / 100.00 |
| 100K B | Database | 49 | 1.32 GiB | 80.28 / 212.23 |
The combined database, scheduler and SSP work directory occupied 355.20 MiB for 10K compact, 363.82 MiB for 10K PGN, 941.75 MiB for 100K standard and 940.91 MiB for the 100K repeat. Serialized fixture bytes above do not include indexes, changefeeds, replica state or other platform storage.
Replica and publication backlog
Scheduler telemetry runs every second; SSP information is sampled roughly every five seconds. Peak values below can come from different observations. The last sample can precede final verification, and successful window verification does not prove every queue is empty.
| Case | Scheduler samples | Peak / last pending events | Peak / last sequence lag |
|---|---|---|---|
| 10K games | 35 | 12,390 / 12,390 | 12,390 / 12,390 |
| 10K + moves | 35 | 12,390 / 12,390 | 12,390 / 12,390 |
| 100K A | 149 | 22,444 / 0 | 26,533 / 0 |
| 100K B | 166 | 16,945 / 0 | 18,945 / 0 |
| Case | SSP observations | Peak publication batches / operations | Peak publication bytes | Oldest pending age (s) | Overloads observed |
|---|---|---|---|---|---|
| 10K games | 6 | 957 / 98,096 | 12.05 MiB | 12.72 | 25 |
| 10K + moves | 6 | 957 / 98,099 | 12.05 MiB | 13.13 | 24 |
| 100K A | 22 | 957 / 98,098 | 12.05 MiB | 13.66 | 17 |
| 100K B | 29 | 938 / 96,158 | 11.82 MiB | 8.47 | 21 |
No scheduler sampling errors or parked publication batches were observed. All four cases verified the exact nineteen retained registrations at completion, with a maximum of twenty during scrolling.
Graph sharing and memory attribution
With view sharing enabled, adding the identical subscriber kept graph count at 17 and increased merged followers from 0 to 1 in every case. A follower shares another registration’s computation; the graph owner is excluded from the follower count. After import, the smoke cases retained sharing, while both 100K cases had nineteen independent graphs and zero merged followers. Later memory observations include that change in graph sharing.
Expensive memory attribution runs between measured phases. These heap ownership estimates are separate from process RSS:
| Case, after import | Shared store | Query/operator state | Total estimate | Graphs / merged followers |
|---|---|---|---|---|
| 10K games | 6.32 MiB | 46.24 MiB | 52.55 MiB | 18 / 1 |
| 10K + moves | 22.62 MiB | 46.24 MiB | 68.86 MiB | 18 / 1 |
| 100K A | 99.83 MiB | 492.72 MiB | 592.55 MiB | 19 / 0 |
| 100K B | 99.83 MiB | 492.72 MiB | 592.55 MiB | 19 / 0 |
Resource-limited tiers
In these reports, neither 1M case nor the 10M case ran. The standard run excluded 1M compact on projected host memory and 1M PGN on projected disk, host memory and Docker memory. The large run excluded all three larger cases on those three checks. Both commands exited nonzero, retaining their completed 100K results.
The following are capacity projections from the large run, not measured consumption or minimum hardware requirements. They scale the completed compact case by populated rows, including the 33,000 load inserts, with a 1.25× margin. PGN projections also include payload allowances.
| Unrun case | Projected work-directory disk | Projected scheduler + SSP RSS | Projected database memory | Status |
|---|---|---|---|---|
| 1M compact | 8.92 GiB | 17.74 GiB | 12.86 GiB | Resource-limited |
| 1M PGN | 12.86 GiB | 19.18 GiB | 13.59 GiB | Resource-limited |
| 10M compact | 86.64 GiB | 172.30 GiB | 124.94 GiB | Resource-limited |
Capacity checks reserve 5 GiB free disk and cap projected native plus database memory at 80% of host RAM, with database memory additionally capped at 80% of Docker RAM. The native projection sums individual process peaks, which need not occur simultaneously. There are no measured latency or throughput results for these excluded tiers.
Report provenance
The main page calls the standard 100K case A and the large-profile repeat
B. Both have the same initial dataset. Numbers come from these local report
directories under apps/benchmark/results/:
| Profile | Directory |
|---|---|
| Smoke | whitepawn-2026-10-11T19-09-13-673Z |
| Standard | whitepawn-2026-10-11T19-16-45-049Z |
| Large | whitepawn-2026-10-11T19-21-12-522Z |
Raw directories are local outputs and are not published with this site. Each
contains run.json, samples.json, summary.md and failure logs when applicable.
The recorded checkout base was 83273b8c, with benchmark changes in the working
tree. The benchmark adds no arbitrary latency pass/fail budget.
Older tests that measure the engine without the full sync path
These May 1, 2026 microbenchmarks start at POST /ingest and stop when the
SSP’s in-memory result hash changes. They bypass database write acknowledgement
and changefeed transport, and do not measure durable publication or client
visibility. They should not be compared directly with the WhitePawn times.
For example, a 1,000-row result changed in about 3 ms, while a 30,000-row result changed in about 27 ms in the flat-query fixture:
Values are means of three per-run p50 measurements, rather than pooled percentiles. The tracked aggregate contains the source data for query shapes, row sizes, view counts and request rates. Those small fixtures do not establish capacity for arbitrary queries.