Benchmarks

A cache raises two questions: how much does it help, and what does it cost? No single number answers both, so this page reports several. Everything below comes from one run of the benchmark harness in bench/: Node v24.19.0, seed 1, load profile steady, 7 scenarios, 26 cached configurations, 31,718 measured requests.

None of the numbers on this page are typed by hand. They are all filled in from the run's JSON when the page is generated, so you can re-run the benchmark on your own hardware and regenerate the page to see your own results.

The whole run in one figure: every workload with ocache against the same workload without it, ranked by how much p99 latency improved, over the medians across all of them. Each row here shows one storage backend — the chart names it — and one offered rate; the rest of this page takes that apart.

ocache under loadp99 latency with and without ocache for 7 workloads, origin-call reduction against each no-cache baseline, and the medians across all of them.ocache under load7 workloads, each run twice at the same offered rate: once through ocache on memory storage, once with no cache in thepath. Both runs use the same seed, so the only difference between the two marks on a row is the cache.2.8xmedian p99 latency improvement93%median origin-call reduction-56%median CPU per request+22 µsmedian cost of one cache hitno cachewith ocachefasterorigin-call reduction0.11101001,00010,000Rate-limited upstream proxy0.338652,590x93%Markdown render (function)25.1659623.7x93%OG image render18412296.7x98%SSR product page42.271202.8x94%JSON list API42.6684.422.0x93%Fan-out aggregation (function)4507861.7x77%Personalized dashboard99.251501.5x82%p99 latency in ms · log scaleNode v24.19.0 · seed 1 · steady load

#How the benchmark works

Each scenario models a realistic workload rather than a library feature: a page render, a JSON list, a per-user dashboard, an image render, an upstream API with a rate cap. Every scenario runs twice — once with ocache in the path and once without — so the difference between the two rows is exactly what caching changed. The cached side is then repeated across several storage backends. Backend latency is simulated from real-world p50/p99 figures rather than set to zero. This run covers memory, redis-local, redis-az, redis-az-bytes, sql, kv-edge, object-store; each scenario uses the backends that make sense for it, and its chart lists which ones.

A backend name ending in -bytes is not a different backend. It is the same simulated wire as its twin — redis-az-bytes and redis-az share one latency model exactly — running through createBlobStorage, which stores each entry as one byte frame instead of a JSON document. The pair is there so the codec can be read off the difference between two rows rather than argued about.

A few choices keep the results honest:

  • Requests do not wait for each other. They arrive on a fixed random schedule (a Poisson process), and latency is measured from when a request should have started, not when the server got around to it. A benchmark that waits for each response before sending the next one hides queueing delay — the thing users actually feel under load.
  • Waiting and computing are modelled separately. When the origin waits on I/O (a database, an upstream call), caching that away buys latency. When the origin computes on the main thread, caching it away buys capacity — the server can handle more traffic. Most scenarios include both.
  • Caches are warmed up first. The hit ratio you see reflects the shape of the traffic, not how short the measurement was — which is what a long-running server actually experiences.
  • Both sides see identical traffic. Same keys, same arrival times, same simulated latencies. The only difference is whether ocache is in the path.

Note

Handlers are called directly in process — no network socket, no HTTP parser, no framework — so the absolute request rates are higher than a real deployment reaches. What matters is the comparison between the no-cache and cached rows, not the absolute numbers. The harness README explains the full method and its known limits.

#How much caching helps

scenariooffered rpsno-cache p99 (ms)cached p99 (ms)p99 vs no cacheorigin-call reduction
ssr-product-page6012042.27–59.582.02x–2.84x93.5%
api-list30084.4242.66–57.441.47x–1.98x93.0%
personalized-dashboard22015099.25–1001.50x–1.51x81.7%
og-image41229184–1856.65x–6.68x98.0%
upstream-proxy258650.33–1028.46x–2589.70x92.7%–93.4%
markdown-render3059625.16–1135.26x–23.69x92.7%
fanout-aggregate90786450–4611.70x–1.75x76.7%–77.3%

Look at the whole range, not just the best row. Across every cached configuration in this run, p99 latency improves between 1.47x and 2589.70x, with a median of 2.81x. What sets the extremes is the origin, not the cache: when the origin is the bottleneck — a render that blocks the main thread, or an upstream that only allows so many requests at once — a cache hit removes an entire queue, not just one round trip, and the improvement becomes enormous. That is what happens to upstream-proxy at 2589.70x.

The scenario that gains the least is personalized-dashboard, at 1.51x even on its fastest backend — and yet it still reduces origin invocations by 81.7%. That is the point: faster responses and less origin work are two separate wins, and a workload can get one without the other. Hard cases like this one — per-user keys, short-lived sessions, many distinct keys — are included on purpose. A benchmark made only of easy wins would not tell you anything.

Origin-call reduction compares foreground and background origin invocations per admitted measured request with the matching no-cache row. It maps to origin work, but is deliberately not described as a request-level cache-hit share: deduplicated misses can share one invocation, while a stale response can launch a background refresh. Here it ranges from 76.7% to 98.0%, driven by traffic distribution and key cardinality:

Origin-call reductionReduction in origin invocations per admitted measured request for each scenario and storage backend.Origin-call reductionReduction in origin invocations per admitted request against the matching no-cache row, including deduplication andbackground refreshes. This — not the speedup — maps to origin work. The no-cache rows are left out.0%25%50%75%100%ssr-product-pagememory93.5%redis-az93.5%redis-az-bytes93.5%kv-edge93.5%api-listmemory93.0%redis-az93.0%kv-edge93.0%personalized-dashboardmemory81.7%redis-az81.7%og-imagememory98.0%redis-az98.0%redis-az-bytes98.0%upstream-proxymemory93.4%redis-az93.4%object-store92.7%markdown-rendermemory92.7%redis-local92.7%redis-az92.7%redis-az-bytes92.7%sql92.7%object-store92.7%fanout-aggregatememory77.3%redis-az77.3%kv-edge76.7%tiered mem+redis-az77.3%tiered mem+kv-edge77.2%0%25%50%75%100%origin-call reduction

#Latency, scenario by scenario

One chart per scenario: p50 through p99 for every configuration on a log axis, with the main-thread CPU spent per request next to each row. The bold no cache row is the workload without ocache; every row below it is the same workload running through one storage backend.

ssr-product-page latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of ssr-product-page on a log scale, with main-thread CPU per request beside each row.SSR product page · ssr-product-page40 KiB HTML, 2 DB queries + template render, 5k pages, Zipf head. Response latency for each configuration, from themedian (p50) to the slowest 1% of requests (p99).p50p99cpu/req (ms)0.11101001000latency in ms · log scaleno cache12049.9713.432memory42.270.172.531redis-az43.161.203.490redis-az-bytes43.211.193.474kv-edge59.588.223.901
api-list latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of api-list on a log scale, with main-thread CPU per request beside each row.JSON list API · api-list8 KiB JSON, 3k query combinations, 40% of requests carry tracking params. Response latency for each configuration, fromthe median (p50) to the slowest 1% of requests (p99).p50p99cpu/req (ms)0.010.1110100latency in ms · log scaleno cache84.4231.102.879memory42.660.101.450redis-az43.440.861.883kv-edge57.446.982.162
personalized-dashboard latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of personalized-dashboard on a log scale, with main-thread CPU per request beside each row.Personalized dashboard · personalized-dashboard15 KiB per-user page, 2k returning users plus 15% first-time sessions, tier cookie keyed. Response latency for eachconfiguration, from the median (p50) to the slowest 1% of requests (p99).p50p99cpu/req (ms)0.11101001000latency in ms · log scaleno cache15055.653.917memory99.250.131.957redis-az1001.222.502
og-image latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of og-image on a log scale, with main-thread CPU per request beside each row.OG image render · og-image64 KiB PNG, 180 ms blocking render, 300 slugs, 30% conditional requests. Response latency for each configuration, fromthe median (p50) to the slowest 1% of requests (p99).p50p99cpu/req (ms)0.1110100100010000latency in ms · log scaleno cache1229370182.089memory1840.228.058redis-az1851.568.989redis-az-bytes1851.338.797
upstream-proxy latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of upstream-proxy on a log scale, with main-thread CPU per request beside each row.Rate-limited upstream proxy · upstream-proxy4 KiB JSON, 300 ms upstream capped at 10 concurrent calls, 20 keys. Response latency for each configuration, from themedian (p50) to the slowest 1% of requests (p99).p50p99cpu/req (ms)0.11101001000latency in ms · log scaleno cache8653234.464memory0.330.192.153redis-az2.790.812.900object-store10229.523.637
markdown-render latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of markdown-render on a log scale, with main-thread CPU per request beside each row.Markdown render (function) · markdown-render25 ms blocking render to 20 KiB HTML, 3k documents. Response latency for each configuration, from the median (p50) tothe slowest 1% of requests (p99).p50p99cpu/req (ms)0.010.11101001000latency in ms · log scaleno cache59610025.789memory25.160.073.618redis-local25.490.253.802redis-az26.300.874.423redis-az-bytes26.340.904.478sql29.052.564.986object-store11332.615.002
fanout-aggregate latencyMedian (p50) and slowest-1% (p99) response latency for every configuration of fanout-aggregate on a log scale, with main-thread CPU per request beside each row.Fan-out aggregation (function) · fanout-aggregate3 parallel upstreams, 220 ms slowest, 500 keys, SWR with a long stale window. Response latency for each configuration,from the median (p50) to the slowest 1% of requests (p99).p50p99cpu/req (ms)0.010.11101001000latency in ms · log scaleno cache7863226.537memory4500.052.903redis-az4520.773.605kv-edge4616.754.239tiered mem+redis-az4520.052.987tiered mem+kv-edge4610.053.015

#More capacity, not just lower latency

A cache hit skips whatever work the origin would have done on the main thread — and that work is what limits how much traffic one server instance can handle. In the heaviest scenario here, og-image, main-thread CPU drops from 182.089 ms per request to 8.058 ms. When the origin mostly waits on I/O instead, the same hit buys latency but very little capacity — the CPU column in the charts above shows which case you are in.

#What a cache hit costs

Serving from cache is not free: ocache has to hash a key, check the entry is still valid, decode it, and build a Response. Measured against in-memory storage with nothing else running, a hit adds 7.8 to 29.8 µs for a cached handler and 4.1 to 4.5 µs for a cached function, across payloads from 4 KiB to 64 KiB.

payloadhandler direct (µs)handler cached (µs)handler adds (µs)function adds (µs)
4 KiB35.855.2+19.5+4.3
6 KiB37.958.9+21.0+4.5
8 KiB51.058.8+7.8+4.3
15 KiB52.481.0+28.6+4.1
20 KiB56.681.7+25.1+4.5
40 KiB92.3114.2+21.9+4.2
64 KiB119.7149.5+29.8+4.2
Hit-path costExtra microseconds a cache hit adds per call, cached handler versus cached function, across payload sizes.Hit-path costExtra microseconds a cache hit costs compared to calling the origin directly: hashing the key, validating and decodingthe entry, and — for handlers — building a Response. Measured against in-memory storage with nothing else running.handlerfunction0102030microseconds added per hit4 KiB19.5 us4.3 us6 KiB21.0 us4.5 us8 KiB7.8 us4.3 us15 KiB28.6 us4.1 us20 KiB25.1 us4.5 us40 KiB21.9 us4.2 us64 KiB29.8 us4.2 us

Notice the cost does not grow with payload size — nothing on the hit path copies the body, so the variation between rows is per-call work and measurement noise, not a size effect. The handler path costs several times the function path, and that gap is the price of being a real HTTP cache instead of a simple memo table: deriving a key from the request, validating the entry, merging headers, and building a Response on every call.

#When caching is not worth it

A hit is never free, and neither is the storage read that finds it: caching trades a trip to your origin for a trip to your storage backend. Adding the largest measured hit-path overhead (0.03 ms) to each backend's median read time gives the break-even point — the minimum origin cost a handler needs before caching pays off at all.

backendread p50 (ms)read p99 (ms)a handler pays off above (ms)models
memory000.03in-process Map (createMemoryStorage)
redis-local0.120.50.15unix socket or sidecar valkey
redis-az0.630.63same-region TCP
redis-az-bytes0.630.63same-region TCP, one blob per entry (createBlobStorage)
sql2152.03Postgres or D1 key-value table
kv-edge6406.03Cloudflare KV / Deno KV, eventually consistent
object-store3012030.03S3 / R2 GetObject

If your handler is faster than its row, it is faster with no cache at all. Two things raise the bar further: a low hit ratio, because every miss pays for a storage read that found nothing, and a remote backend, which also has to decode the response body on your thread — that part does grow with payload size, and this table does not include it.

Tip

The storage profiles are estimates fitted to a published p50/p99 pair, not measurements of your backend. They live in one table in bench/harness/storage.ts — measure your own backend and edit it before quoting any of these numbers.

#Reproducing this page

pnpm bench --json=bench/results/steady.json --md=bench/results/steady.md
pnpm bench:docs bench/results/steady.json

The first command runs every scenario and writes the full report, including the tables this page summarizes. The second renders the charts, inlines them, and regenerates this page from the same JSON — the charts are part of the page, not files next to it. (pnpm bench:chart writes them out as standalone SVGs if you want them separately.) The prose lives in bench/docs.md; the numbers never do.