Benchmarks
How fast one Spillway process is on a realistic site, measured the same way every time, with the numbers from before and after the performance pass and the targets the project aims for. The script is scripts/bench.mjs (pnpm bench); the numbers in benchmarks.json next to this file are the recorded reference for this machine; CI compares a pull request against its base branch measured on the same runner.
What is measured
pnpm bench starts the built server in-process (packages/core/dist) on a temporary SQLite file, and on Postgres as well when TEST_DATABASE_URL is set (a bench_<random> schema, dropped afterwards), and seeds a site through the API:
- the higher-ed kit with its sample content;
- 2,000 more published entities over three types (700 pages built from blocks with rich text in HTML mode and library images, 700 news items with a rich text body and an image, 600 programs with two rich text fields and taxonomy terms), each with 2 to 4 KB of rich text that links other entities and embeds a library image;
- 50 listing pages whose Collection block exposes a keyword search, taxonomy filters and a pager over 10 collections;
- 5 extra locales, with the first 200 news items translated and published in each (1,000 translations);
- 300 redirects, a 20-item main menu linking entities, 200 draft pages for the save scenario, 12 library images of 1200 by 675 pixels.
It then warms the page cache and runs each scenario for 20 seconds (--quick: 5 seconds) with a small load generator built on node:http: 32 keep-alive connections, each sending the next request as soon as the previous answer has been read, Accept-Encoding: gzip, br like a browser. Latency is measured from the start of a request to the end of its body; the first second of each scenario is a warm-up that is not measured. The process's resident set size is sampled every 100 ms.
| Scenario | What it sends |
|---|---|
page_hit | 50 published page paths, round robin, all in the cache (X-Spillway-Cache: HIT checked) |
page_hit_c1 | the same with one connection: the in-process service time of a hit without queueing |
page_miss | every one of the 2,000+ published paths in turn with a unique query string; plain pages keep one entry per path, so the misses come from cycling through four times more pages than the cache holds (500) |
collection_filters | /programs and the 50 listing pages with a keyword, a degree level, a department and a page number drawn at random: hundreds of variants per path, so most are renders |
search | /search?q=<two words> from a 120-word pool: mostly renders |
sitemap | /sitemap.xml (the index) and /sitemap-en.xml alternately |
api_render_hit | GET /api/public/render/<path> for the 50 warm paths |
api_render_miss | the same over all paths |
admin_list_p1, admin_list_p40 | GET /api/entities?limit=50&page=1 and page=40 with an admin session |
entity_save | PUT /api/entities/:id on a pool of 200 drafts (every save writes a revision, the search index row, references and the audit row) |
derivative_first | /media/<id>/<file>?w=&q=&fmt= with a variant that does not exist yet on every request (12 images, 7 widths, 3 qualities, 4 formats), each as a different visitor (X-Forwarded-For, TRUSTED_PROXIES=loopback), since derivative misses are limited per visitor |
import_crawl | one import job over a 300-page fake site served in-process through the importer's fetch option, rateLimitMs: 100, from POST /api/imports to status crawled: the wall time, not a distribution |
--only a,b runs a subset, --profile DIR writes a .cpuprofile per scenario (open it in Chrome DevTools or node --prof-process-style tooling), --keep leaves the seeded database in place for EXPLAIN work, --write updates benchmarks.json and the "Latest run" section below, --compare docs/dev/benchmarks.json fails the run when a scenario's p50 is more than 50% (and more than 1 ms) above the baseline; the p95s are printed next to it. The import scenario compares its total time. The median is the metric because a 5 second run holds too few requests for a stable tail: the first second of every scenario is a warm-up whose requests are served but not measured (--warmup), so the queue that forms while 32 connections start at once does not decide the numbers. CI builds the pull request's base branch in a worktree, runs this script against it with --root ../base, then runs the head with --compare against that result, so the gate never depends on the machine the checked-in numbers were recorded on (hosted runners are about twice as slow as the machine behind benchmarks.json). --ignore a,b leaves scenarios out of the gate; by default derivative_first (too few requests in a quick run) and import_crawl (rate-limit bound) are reported but not gated. Refresh benchmarks.json with pnpm bench --write when a change moves the numbers on purpose.
Targets
Stated targets, not promises; the "After" tables say where the build stands on the benchmark machine.
| Target | Measure |
|---|---|
| Page cache hit: p95 under 5 ms in-process | page_hit_c1 p95 (one connection; under 32 connections the p95 is roughly 32 times the service time) |
| Page cache miss: p95 under 150 ms for a typical page on SQLite | page_miss p95 on SQLite |
| Admin content list: p95 under 100 ms at 2,000 entities | admin_list_p1 and admin_list_p40 p95 |
| Import of 300 pages under 2 minutes with a 100 ms rate limit | import_crawl |
Before
<!-- bench:before:start -->
The build before this pass (the same commit's dist before the changes below), run on 2026-11-06: Intel(R) Xeon(R) Processor @ 2.10GHz, 4 cores, 16 GB, Linux 6.18.44-fc-v242, Node v22.23.4; 32 connections, 20 s per scenario.
SQLite
| Scenario | Requests | p50 ms | p95 ms | p99 ms | req/s | Errors | Cache hits | Max RSS MB |
|---|---|---|---|---|---|---|---|---|
| page_hit | 27583 | 21.6 | 34.7 | 44.3 | 1379 | 0 | 100% | 266 |
| page_hit_c1 | 7069 | 0.64 | 0.97 | 2.82 | 1413 | 0 | 100% | 267 |
| page_miss | 3124 | 190 | 284 | 351 | 156 | 0 | 0% | 314 |
| collection_filters | 48 | 31204 | 57278 | 59617 | 1 | 3 | 0% | 323 |
| search | 406 | 1493 | 2409 | 11399 | 19 | 0 | 0% | 326 |
| sitemap | 493 | 1285 | 1398 | 8996 | 24 | 0 | - | 775 |
| api_render_hit | 43628 | 13.7 | 20.8 | 25.5 | 2181 | 50 | 100% | 767 |
| api_render_miss | 4803 | 130 | 161 | 202 | 239 | 0 | 0% | 767 |
| admin_list_p1 | 680 | 925 | 1013 | 5529 | 33 | 0 | - | 779 |
| admin_list_p40 | 484 | 1275 | 1540 | 9232 | 23 | 0 | - | 777 |
| entity_save | 1702 | 368 | 431 | 924 | 84 | 0 | - | 767 |
| derivative_first | 241 | 2716 | 5108 | 6350 | 10 | 0 | - | 1081 |
| import_crawl | 1 | 30476 | 30476 | 30476 | - | 0 | - | 986 |
RSS: 142 MB at start, 211 MB after seeding, 265 MB after warming, 986 MB at the end (after a GC); page cache 500/500 entries. Import crawl: 300 pages in 30.5 s (crawled), 100 ms between requests to the source.
Postgres
| Scenario | Requests | p50 ms | p95 ms | p99 ms | req/s | Errors | Cache hits | Max RSS MB |
|---|---|---|---|---|---|---|---|---|
| page_hit | 38802 | 15.7 | 24.2 | 30.3 | 1939 | 0 | 100% | 975 |
| page_hit_c1 | 4540 | 0.96 | 1.73 | 4.01 | 908 | 0 | 100% | 975 |
| page_miss | 1466 | 433 | 641 | 707 | 72 | 0 | 1% | 975 |
| collection_filters | 512 | 1230 | 1893 | 2367 | 25 | 0 | 0% | 999 |
| search | 424 | 1504 | 1916 | 3005 | 21 | 0 | 0% | 986 |
| sitemap | 599 | 862 | 1867 | 2032 | 28 | 0 | - | 1382 |
| api_render_hit | 58973 | 9.93 | 15.7 | 22.1 | 2948 | 68 | 100% | 1371 |
| api_render_miss | 2385 | 236 | 421 | 535 | 118 | 0 | 1% | 1369 |
| admin_list_p1 | 3592 | 175 | 234 | 271 | 178 | 0 | - | 1495 |
| admin_list_p40 | 4802 | 130 | 184 | 240 | 239 | 0 | - | 1476 |
| entity_save | 2602 | 247 | 334 | 371 | 129 | 0 | - | 1370 |
| derivative_first | 86 | 9940 | 16455 | 18573 | 3 | 0 | - | 1498 |
| import_crawl | 1 | 30509 | 30509 | 30509 | - | 0 | - | 1379 |
RSS: 986 MB at start, 971 MB after seeding, 976 MB after warming, 1379 MB at the end (after a GC); page cache 500/500 entries. Import crawl: 300 pages in 30.5 s (crawled), 100 ms between requests to the source.
The 50 and 68 errors on api_render_hit are the hit check failing: the scenarios before it had cycled more pages through the cache than it holds, so some of the 50 warm paths had been evicted (the script now warms them again right before the hit scenarios). The 3 errors on collection_filters are requests that took longer than the load generator's connection lived.
<!-- bench:before:end -->
After
<!-- bench:after:start -->
This build, run on 2026-11-06 at commit 134b88e (its dist also carries the CDN purge hook, the registry organizations and the module blocks that landed on the branch in the meantime; the "Before" build predates them): Intel(R) Xeon(R) Processor @ 2.10GHz, 4 cores, 16 GB, Linux 6.18.44-fc-v242, Node v22.23.4; 32 connections, 20 s per scenario.
SQLite
| Scenario | Requests | p50 ms | p95 ms | p99 ms | req/s | Errors | Cache hits | Max RSS MB |
|---|---|---|---|---|---|---|---|---|
| page_hit | 20024 | 29.7 | 39.8 | 45.1 | 1053 | 0 | 100% | 231 |
| page_hit_c1 | 1690 | 2.20 | 3.03 | 5.57 | 422 | 0 | 100% | 232 |
| page_hit_identity | 27444 | 21.9 | 27.0 | 31.1 | 1444 | 0 | 100% | 234 |
| page_miss | 5872 | 101 | 129 | 149 | 308 | 0 | 0% | 371 |
| collection_filters | 648 | 907 | 1023 | 1052 | 33 | 0 | 0% | 393 |
| search | 539 | 1089 | 1154 | 1166 | 28 | 0 | 0% | 380 |
| sitemap | 20555 | 27.7 | 51.7 | 67.1 | 1081 | 0 | - | 419 |
| api_render_hit | 24808 | 23.8 | 31.4 | 35.7 | 1305 | 0 | 100% | 383 |
| api_render_miss | 7992 | 74.1 | 94.6 | 108 | 420 | 0 | 0% | 384 |
| admin_list_p1 | 3432 | 176 | 219 | 239 | 180 | 0 | - | 459 |
| admin_list_p40 | 6149 | 95.4 | 130 | 148 | 323 | 0 | - | 413 |
| admin_list_c1 | 415 | 6.63 | 17.8 | 22.1 | 104 | 0 | - | 388 |
| entity_save | 1350 | 445 | 483 | 496 | 70 | 0 | - | 382 |
| derivative_first | 162 | 3595 | 6029 | 7599 | 6 | 0 | - | 638 |
| import_crawl | 1 | 30484 | 30484 | 30484 | - | 0 | - | 544 |
RSS: 135 MB at start, 210 MB after seeding, 222 MB after warming, 544 MB at the end (after a GC), 552 MB after a second 5 s pass of cache misses (after a GC); page cache 500/500 entries. Import crawl: 300 pages in 30.5 s (crawled), 100 ms between requests to the source.
Postgres
| Scenario | Requests | p50 ms | p95 ms | p99 ms | req/s | Errors | Cache hits | Max RSS MB |
|---|---|---|---|---|---|---|---|---|
| page_hit | 20777 | 27.6 | 40.1 | 55.0 | 1093 | 0 | 100% | 557 |
| page_hit_c1 | 1484 | 2.55 | 3.42 | 6.60 | 371 | 0 | 100% | 557 |
| page_hit_identity | 37304 | 15.8 | 22.1 | 26.7 | 1962 | 0 | 100% | 558 |
| page_miss | 4204 | 144 | 169 | 183 | 221 | 0 | 0% | 783 |
| collection_filters | 617 | 968 | 1520 | 1676 | 32 | 0 | 0% | 854 |
| search | 501 | 1155 | 1686 | 1852 | 26 | 0 | 0% | 794 |
| sitemap | 16496 | 37.6 | 68.8 | 80.1 | 868 | 0 | - | 823 |
| api_render_hit | 28907 | 20.2 | 28.2 | 37.1 | 1520 | 0 | 100% | 789 |
| api_render_miss | 5761 | 103 | 135 | 156 | 302 | 0 | 0% | 789 |
| admin_list_p1 | 2428 | 249 | 294 | 316 | 127 | 0 | - | 856 |
| admin_list_p40 | 3594 | 166 | 202 | 226 | 188 | 0 | - | 850 |
| admin_list_c1 | 278 | 11.9 | 23.7 | 26.2 | 69 | 0 | - | 788 |
| entity_save | 2246 | 265 | 320 | 360 | 117 | 0 | - | 785 |
| derivative_first | 195 | 3005 | 5677 | 7683 | 9 | 0 | - | 897 |
| import_crawl | 1 | 30597 | 30597 | 30597 | - | 0 | - | 786 |
RSS: 552 MB at start, 556 MB after seeding, 557 MB after warming, 786 MB at the end (after a GC), 787 MB after a second 5 s pass of cache misses (after a GC); page cache 500/500 entries. Import crawl: 300 pages in 30.6 s (crawled), 100 ms between requests to the source.
How the targets stand on this machine (SQLite): a cache hit takes 0.45 ms at p50 and 0.78 ms at p95 on one connection with gzip, served from the copy compressed once per cache entry (it took 2.2 ms when every hit was compressed on the fly), and compression cuts a 60 KB page to about 12 KB on the wire; a cache miss is 101 ms at p50 and 129 ms at p95 under 32 connections (190 and 284 before); the admin list takes 6.6 ms at p50 and 18 ms at p95 on one connection (admin_list_c1), which under 32 connections queues to 95 to 176 ms at p50 (925 to 1,275 ms before); the 300-page import takes 30.5 s, which is the 100 ms rate limit times 300 pages plus the robots and sitemap reads.
Two numbers moved the wrong way and both are explained by what the response carries, not by the render: page_hit and api_render_hit under 32 connections pay for gzip (see page_hit_identity), and entity_save on SQLite went from 368 to 445 ms at p50: the save's profile shows 60% of it in the search index row (an FTS5 delete that scans the table, unchanged) and 10% in the CDN purge hook's key index walk, which the "Before" build did not have yet. Both were fixed after this run (see "What changed": the search index rowid map and the hook's reverse map); pnpm bench --only entity_save --quick on the fixed build gives 180 ms at p50 on SQLite against 416 ms for the same quick run before it. derivative_first has the throughput of a CPU-bound encoder (AVIF dominates; 6 to 12 requests a second either way) with 400 MB less memory.
The "Before" run measured every request from the first; this run leaves the first second of each scenario out (the warm-up the script now applies), which lowers the tails of the slow scenarios (collection_filters, search) by the queue that forms while 32 connections start at once and changes nothing else by more than noise.
<!-- bench:after:end -->
What changed
<!-- bench:changes:start -->
Everything below keeps the rendered output, the API responses and the data identical; the profiles (pnpm bench --profile DIR, one .cpuprofile per scenario) and EXPLAIN on both dialects decided what to change.
| Where | What the profile showed | Change |
|---|---|---|
Collection keyword filter (marketing/collections.ts) | On SQLite the FTS5 table cannot be looked up by entity_id, so the correlated EXISTS (... WHERE si.entity_id = entity.id ...) per word scanned the whole index once per candidate entity: 31 s per listing page with a keyword. | One uncorrelated entity.id IN (SELECT entity_id FROM search_index WHERE <every word>): one pass over the index per query (26 ms; 1.2 s to 45 ms per page under load). |
sqlHelpers.ilike on SQLite (db/index.ts) | lower(col) LIKE lower(?) copied every candidate value through lower(), which folds ASCII only, exactly what LIKE already ignores by default. | col LIKE ? ESCAPE '\': the same matches, half the cost of a keyword scan (12.4 ms to 6.5 ms over 3,300 index rows). |
Admin content list (repos/entities.ts, migration 0032_entity_list_index) | EXPLAIN QUERY PLAN on SQLite: SEARCH entity USING INDEX entity_site_idx then USE TEMP B-TREE FOR ORDER BY, so every page sorted every row of the site (42 ms at 3,300 entities); Postgres walked entity_updated_idx backwards with an incremental sort (2 ms). | Index (site_id, workstream_id, updated_at DESC, id ASC): on SQLite the page is an index walk (0.5 ms) and the count a covering scan; Postgres keeps its plan on one site and uses the new index where the updated_at order would walk other sites' rows first. A count(*) OVER () on the page was measured and rejected: SQLite materialises the whole set for a window function (47 ms). |
Sitemap (routes/seo.ts, marketing/seo.ts) | Every request read every published page of the site including its data column (10 to 20 KB of layout each) and parsed it, for a document that uses path and dates only: 1.3 s per request under load, RSS up by 450 MB. | listPublishedPages(..., { data: false }) leaves the column out; the built XML is cached under site:<id> (evicted by every publish, unpublish, path move and settings save) for at most five minutes; /api/public/paths skips the column too. |
Site search (search/sqlite.ts, search/postgres.ts, marketing/search.ts) | Three ranking queries per search (count, facets, page) and one entity lookup per hit. | The total is the sum of the type facets (every hit has one type), so the count query is gone; the page's hits are looked up in one IN query. What remains is FTS5's own ranking of every match (with a 120-word corpus almost every document matches both words: 5 ms facets + 12 ms page, 17 ms with the locale filter) and the page render. |
Menus (menus/index.ts, repos/entities.ts) | Every item that links an entity cost a getEntity, often a translation lookup and a home page lookup: 30 to 60 queries per render for a 20-item menu (and one round trip each on Postgres), each parsing the linked page's data. | The whole tree's entities, their translations in the page's locale and the home page come from three narrow queries (listEntityLinks, listTranslationLinksIn: id, locale, status, path, group, workstream; no data); item by item the logic is unchanged. |
Image derivatives (media/derivatives.ts) | 32 first requests for different variants started 32 sharp pipelines at once: 2.7 s per request on SQLite, 9.9 s on the Postgres run, RSS to 1.1 and 1.5 GB. Generation was already once per variant with a lock. | At most one generation per core at a time (MAX_CONCURRENT_GENERATIONS, 1 to 8); the rest queue in order and check the disk again before generating. Same throughput (the encoders are CPU bound), bounded memory. |
Compression (app.ts) | No compression at all: a 60 KB page went out as 60 KB. | Hono's compress for clients that send Accept-Encoding (gzip or deflate), threshold 1 KB, compressible types only (HTML, JSON, XML, text, SVG, scripts), Vary: Accept-Encoding; the public page, public render and sitemap responses set Content-Length so the threshold applies to them. It cuts the bytes per page to a fifth. Cache hits do not pay for it on every request: the first hit that accepts brotli or gzip compresses the entry once (render/cache.ts, brotli quality 4, gzip level 6) and stores the copy with the entry, with its own ETag (W/"<hash>-br" or -gz), so later hits send stored bytes and the middleware compresses only misses and the API. Measured with --quick at 32 connections: gzip page hits 33.9 ms to 17.0 ms p50 (2.37 ms to 0.45 ms on one connection), public render hits 23.8 ms to 13.8 ms. |
ETag and 304 (render/routes.ts respond(), render/etag.ts, routes/public-render.ts) | No validator on public pages. | A weak ETag of the cached HTML or JSON, computed once per cache entry; If-None-Match answers 304 with the cache headers and no body (and no CSP, so the browser keeps the policy whose nonce is in the body it stored). |
Keep-alive (server.ts) | Node's default 5 s idle timeout, below the 60 s most proxies use. | keepAliveTimeout 65 s, headersTimeout 66 s. |
| React build | The benchmark ran React's development build (react-jsx-runtime.development.js was 16% of a miss, with prop validation on every element). | The benchmark sets NODE_ENV=production like the Docker image; running from source, set it too (docs/install.md). |
Search index writes on SQLite (db/search-index.ts, migration 0033_search_doc) | 60% of a save was the entity's search_index row, and most of that DELETE FROM search_index WHERE entity_id = ?: FTS5 cannot index an UNINDEXED column, so the delete scanned the whole table (3.4 ms at 3,300 rows, linear in the site). | search_doc (rowid INTEGER PRIMARY KEY, entity_id TEXT UNIQUE) maps an entity to the rowid of its index row, which FTS5 looks up directly; the row is deleted and rewritten by rowid (0.67 ms to 0.06 ms per replacement in isolation). The migration maps the existing rows and the server rebuilds the index from the entities once through the search service. pnpm bench --only entity_save --quick (5 s, 32 connections): SQLite p50 416 ms to 180 ms, p95 451 to 206 ms, 72 to 173 req/s; Postgres within noise (p50 209 to 217 ms). |
CDN purge hook (cdn/hook.ts) | schedule() walked every remembered key (key to tags) on every invalidation to find the pages carrying a tag: 10% of a save at 4,000 keys. | A reverse map (tag to keys) next to the key index: an invalidation visits only the keys of its tags (0 for an unrelated tag, the site's pages for site:<id>), and both maps are bounded by the page cache's own capacity (500 in process, 200 in front of Redis) instead of a fixed 4,000. |
Looked at and left alone, with the numbers:
- Rich text sanitising at render (
richtext/sanitize.ts,content/embed.ts): 2% of a cache miss. Caching sanitised HTML by revision would not measurably help. listSiteson every request (auth/site.ts): 5% of a cache hit (one row, seven JSON columns). A memo would need cross-instance invalidation for Postgres and Redis deployments; not worth a behavior change for 0.05 ms.- The save path (
entity_save): the search index row and the CDN hook's index walk, the two items the first pass deferred, are fixed above (migration0033_search_docand the hook's reverse map). What remains of a save is the revision, the references and the audit row, each one write, and the FTS5 insert itself. - The admin list returns 50 full entities with their layouts (the contract):
JSON.stringifyof the response is 23% and parsing the rows 12% of the request. Only a narrower contract would change that. - Search ranking: FTS5 and
ts_rank_cdrank every matching document; the benchmark corpus makes nearly every document match, which is the worst case. Results pages are cached per query for 60 s. - Body limits (
app.ts bodyLimits()) are in place for every route: JSON 1 MB, media 25 MB, module archives 64 MB, registry publishes 20 MB, the form submit and the beacon 256 KB. <!-- bench:changes:end -->
Memory
<!-- bench:memory:start -->
The resident set after the whole SQLite run and a forced GC is 544 MB, and a second five-second pass of cache misses over every page moves it to 552 MB: the caches are full and bounded, nothing accumulates per request. The "Before" build ended the same run at 986 MB (the sitemap scenario alone added 450 MB of parsed data columns, and 32 concurrent sharp pipelines another 300 MB). What holds memory in a loaded process, and its bound:
| Holder | Bound |
|---|---|
Page cache (render/cache.ts, MemoryCache) | 500 entries (HTML, JSON twins and sitemaps alike), at most 50 query variants per path and 40% of the cache for query-dependent entries; ctx.cache.stats() reports entries, hits and misses |
| Redis local layer | 200 entries in front of Redis |
CDN purge hook key index (cdn/hook.ts) | the page cache's capacity (500 in process, 200 in front of Redis), in both its maps (key to tags, tag to keys) |
Redirect map (marketing/redirects.ts) | one map per instance, rebuilt on change (300 redirects here) |
| Derivatives | on disk, at most 48 variants per image; in memory only the generations in flight (at most one per core) and their promises |
Importer scratch (importer/runner.ts) | the crawl's known URL set and queue rows, dropped when the job ends; pages and assets go to the database and DATA_DIR |
Rate limiters (auth/ratelimit.ts) | buckets swept every window |
| Block library, Puck config, FTS mode, redirect map memo | one per process |
| libvips | sharp's own cache (50 MB by default) and thread pool |
The gap between 222 MB after warming and 544 MB at the end is the full page cache (500 pages of 60 to 100 KB plus their JSON twins), libvips's buffers and V8 heap that a GC does not hand back to the OS; it does not grow with more requests. <!-- bench:memory:end -->
Latest run
The section below is rewritten by pnpm bench --write.
<!-- bench:latest:start -->
Run on 2026-11-06 at commit 134b88e: Intel(R) Xeon(R) Processor @ 2.10GHz, 4 cores, 16 GB, Linux 6.18.44-fc-v242, Node v22.23.4; 32 connections, 20 s per scenario.
SQLite
| Scenario | Requests | p50 ms | p95 ms | p99 ms | req/s | Errors | Cache hits | Max RSS MB |
|---|---|---|---|---|---|---|---|---|
| page_hit | 20024 | 29.7 | 39.8 | 45.1 | 1053 | 0 | 100% | 231 |
| page_hit_c1 | 1690 | 2.20 | 3.03 | 5.57 | 422 | 0 | 100% | 232 |
| page_hit_identity | 27444 | 21.9 | 27.0 | 31.1 | 1444 | 0 | 100% | 234 |
| page_miss | 5872 | 101 | 129 | 149 | 308 | 0 | 0% | 371 |
| collection_filters | 648 | 907 | 1023 | 1052 | 33 | 0 | 0% | 393 |
| search | 539 | 1089 | 1154 | 1166 | 28 | 0 | 0% | 380 |
| sitemap | 20555 | 27.7 | 51.7 | 67.1 | 1081 | 0 | - | 419 |
| api_render_hit | 24808 | 23.8 | 31.4 | 35.7 | 1305 | 0 | 100% | 383 |
| api_render_miss | 7992 | 74.1 | 94.6 | 108 | 420 | 0 | 0% | 384 |
| admin_list_p1 | 3432 | 176 | 219 | 239 | 180 | 0 | - | 459 |
| admin_list_p40 | 6149 | 95.4 | 130 | 148 | 323 | 0 | - | 413 |
| admin_list_c1 | 415 | 6.63 | 17.8 | 22.1 | 104 | 0 | - | 388 |
| entity_save | 1350 | 445 | 483 | 496 | 70 | 0 | - | 382 |
| derivative_first | 162 | 3595 | 6029 | 7599 | 6 | 0 | - | 638 |
| import_crawl | 1 | 30484 | 30484 | 30484 | - | 0 | - | 544 |
RSS: 135 MB at start, 210 MB after seeding, 222 MB after warming, 544 MB at the end (after a GC), 552 MB after a second 5 s pass of cache misses (after a GC); page cache 500/500 entries. Import crawl: 300 pages in 30.5 s (crawled), 100 ms between requests to the source.
Postgres
| Scenario | Requests | p50 ms | p95 ms | p99 ms | req/s | Errors | Cache hits | Max RSS MB |
|---|---|---|---|---|---|---|---|---|
| page_hit | 20777 | 27.6 | 40.1 | 55.0 | 1093 | 0 | 100% | 557 |
| page_hit_c1 | 1484 | 2.55 | 3.42 | 6.60 | 371 | 0 | 100% | 557 |
| page_hit_identity | 37304 | 15.8 | 22.1 | 26.7 | 1962 | 0 | 100% | 558 |
| page_miss | 4204 | 144 | 169 | 183 | 221 | 0 | 0% | 783 |
| collection_filters | 617 | 968 | 1520 | 1676 | 32 | 0 | 0% | 854 |
| search | 501 | 1155 | 1686 | 1852 | 26 | 0 | 0% | 794 |
| sitemap | 16496 | 37.6 | 68.8 | 80.1 | 868 | 0 | - | 823 |
| api_render_hit | 28907 | 20.2 | 28.2 | 37.1 | 1520 | 0 | 100% | 789 |
| api_render_miss | 5761 | 103 | 135 | 156 | 302 | 0 | 0% | 789 |
| admin_list_p1 | 2428 | 249 | 294 | 316 | 127 | 0 | - | 856 |
| admin_list_p40 | 3594 | 166 | 202 | 226 | 188 | 0 | - | 850 |
| admin_list_c1 | 278 | 11.9 | 23.7 | 26.2 | 69 | 0 | - | 788 |
| entity_save | 2246 | 265 | 320 | 360 | 117 | 0 | - | 785 |
| derivative_first | 195 | 3005 | 5677 | 7683 | 9 | 0 | - | 897 |
| import_crawl | 1 | 30597 | 30597 | 30597 | - | 0 | - | 786 |
RSS: 552 MB at start, 556 MB after seeding, 557 MB after warming, 786 MB at the end (after a GC), 787 MB after a second 5 s pass of cache misses (after a GC); page cache 500/500 entries. Import crawl: 300 pages in 30.6 s (crawled), 100 ms between requests to the source. <!-- bench:latest:end -->
Spillway 0.1.0 · Edit this page on GitHub