Benchmarks for the Deserve router + DVE view rendering.
- Load tool:
autocannon(npm) - Default config used in this doc: 500 connections, pipelining 10, duration 30s
Start a server (from repo root), then run autocannon from another terminal.
-
Standard mode (view routes + CPU on main thread):
deno run --allow-net --allow-read benchmark/main.ts
-
Listener mode (one empty event listener attached):
deno run --allow-net --allow-read benchmark/main-log.ts
Notes:
- All view routes (
/test-view*) are available. - Listener mode is for measuring observability cost, see Observability Cost.
npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30| Route | What it does | Why it exists |
|---|---|---|
/test |
JSON only (no CPU work) | Baseline throughput |
/test-cpu |
50k sqrt loop on main thread |
Event-loop blocking cost |
| Route | What it renders | Why it exists |
|---|---|---|
/test-view |
Simple variable render | DVE baseline |
/test-view-each-meta |
each block with metadata |
Loop + expression evaluation |
/test-view-include |
Include + partial | Composition overhead |
/test-view-expr |
Optional chaining + expressions | Expression parser/evaluator |
# JSON Baseline
npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30
# CPU On Main Thread
npx autocannon http://localhost:8000/test-cpu -c 500 -p 10 -d 30
# DVE Views
npx autocannon http://localhost:8000/test-view -c 500 -p 10 -d 30
npx autocannon http://localhost:8000/test-view-each-meta -c 500 -p 10 -d 30
npx autocannon http://localhost:8000/test-view-include -c 500 -p 10 -d 30
npx autocannon http://localhost:8000/test-view-expr -c 500 -p 10 -d 30
# Logging Off vs On (start main.ts, then main-log.ts, same command)
npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30- OS: macOS 26.5
- Machine: Apple M3 Pro, 18 GB RAM
- Framework: Deserve (0.15.0)
- Engine: DVE (0.1.1)
- Runtime: Deno 2.9.0 (aarch64-apple-darwin)
- Config: 500 connections, pipelining 10, duration 30s
/test and all view routes are 3 runs each, same machine and config. View
results reflect the include source-text cache (an included partial is read
from disk once and reused, instead of a blocking read per request) together
with the DVE 0.1.1 render pipeline (prepared expressions, pre-split paths,
loop scope reuse, and the sink writer).
| Route | Test 1 | Test 2 | Test 3 | Req/Sec (avg) | Latency (avg) |
|---|---|---|---|---|---|
/test |
199,791 | 198,669 | 198,985 | 199,148 | 24.61 ms |
Takeaway: JSON baseline throughput on the main thread.
| Route | Test 1 | Test 2 | Test 3 | Req/Sec (avg) | Latency (avg) |
|---|---|---|---|---|---|
/test-view |
157,039 | 157,350 | 154,167 | 156,186 | 31.51 ms |
/test-view-each-meta |
28,173 | 28,367 | 28,425 | 28,322 | 175.59 ms |
/test-view-include |
133,275 | 131,987 | 133,453 | 132,905 | 37.12 ms |
/test-view-expr |
145,139 | 143,403 | 144,670 | 144,404 | 34.12 ms |
Takeaway: each and include recovered from the engine extraction. The DVE
0.1.1 render pipeline lifts each-meta and expressions well past the earlier
numbers, and include runs close to the include-free /test-view baseline.
Deserve emits lifecycle events (route, view, request, session, process). By default no listener is attached, so the request path skips all reporting and stays cheap. The moment you attach a listener with router.on(...), every request walks the full reporting path. This section measures that difference on the same /test route.
Off (no listener) uses benchmark/main.ts. On uses benchmark/main-log.ts with an empty listener, so the result reflects the framework cost only, not any logging work:
// benchmark/main-log.ts
import { Router } from '@neabyte/deserve'
const router = new Router({
routes: { directory: 'benchmark/routes' },
views: { directory: 'benchmark/views' }
})
// Empty listener, observability path active
router.on(() => {})
await router.serve(8000)Run the same load against each server:
npx autocannon http://localhost:8000/test -c 500 -p 10 -d 303 runs each, JSON /test route, same machine and config.
| Mode | Run 1 | Run 2 | Run 3 | Req/Sec (avg) | Latency (avg) |
|---|---|---|---|---|---|
| Off (no listener) | 181,158 | 177,024 | 185,173 | 181,118 | 27.12 ms |
| On (empty listener) | 148,309 | 147,170 | 136,182 | 143,887 | 34.30 ms |
Attaching a listener drops throughput to about 79% of the no-listener baseline and raises average latency from 27 ms to 34 ms. This gap is the framework reporting cost, measured with no logging in the listener.
The difference has two parts.
The router only does reporting work when a subscriber exists:
// Skip reporting when nobody listens
const observe = this.events.hasListeners()
const requestStart = observe ? performance.now() : 0With no listener, observe is false, reportRequest is never called, and events.emit(...) returns immediately. This is why the baseline holds ~181k.
Once observe is true, every request is parsed to build the event metadata. This is the framework cost, and it runs even with an empty listener:
performance.now()is read twice (request start and duration).reportRequest()builds metadata forrequest:complete, plusrequest:errorwhen status >= 400.requestMetrics()readscontent-length(request and response), parses the URL for host and port, and readsuser-agent.- One or two event objects are created and dispatched to the listener.
The work your listener itself does is added on top of this, so keep it light on the hot path.
-
Filter first: only act on faults, skip the high-volume success event.
// Only react to error events router.on((event) => { if (event.kind.endsWith(':error')) { handleFault(event.kind, event.metadata) } })
-
Batch off the request path: push events into a buffer, flush on a timer to a sink such as a file or an OTel exporter.
Related events you may want to watch: request:error, view:error, reload:error, session:invalid, and process:error.
benchmark/main.ts: server entrybenchmark/main-log.ts: server entry (one empty event listener)benchmark/routes/*.ts: route handlersbenchmark/views/*.dve: DVE templates