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.
-
Non-worker mode (view routes + CPU on main thread):
deno run --allow-net --allow-read benchmark/main.ts
-
Worker mode (enables
/test-worker):deno run --allow-net --allow-read benchmark/main-worker.ts
-
Listener mode (one empty event listener attached):
deno run --allow-net --allow-read benchmark/main-log.ts
Notes:
- In non-worker mode,
/test-workerreturns 503 (worker not enabled). - All view routes (
/test-view*) are available in both modes. - 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 |
/test-worker |
Same loop offloaded to a worker pool | Offload overhead + scaling |
| 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
# CPU In Worker (Requires benchmark/main-worker.ts)
npx autocannon http://localhost:8000/test-worker -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.12.2)
- Runtime: Deno 2.8.2 (V8 14.9.207.2, TypeScript 6.0.3)
- Config: 500 connections, pipelining 10, duration 30s
2 runs each, non-worker server, same machine and config.
| Route | Test 1 | Test 2 | Req/Sec (avg) | Latency (avg) | Total (avg) |
|---|---|---|---|---|---|
/test |
150,950 | 148,637 | 149,794 | 32.87 ms | 4,494k |
/test-cpu |
22,442 | 22,148 | 22,295 | 223.04 ms | 669k |
Takeaway: /test-cpu blocks the event loop on the main thread, see worker mode to move CPU work off-thread.
| Route | Test 1 | Test 2 | Req/Sec (avg) | Latency (avg) | Total (avg) |
|---|---|---|---|---|---|
/test-view |
118,026 | 118,323 | 118,175 | 41.81 ms | 3.55M |
/test-view-each-meta |
8,522 | 8,530 | 8,526 | 580.44 ms | 256k |
/test-view-include |
103,633 | 106,489 | 105,061 | 47.09 ms | 3.15M |
/test-view-expr |
79,089 | 79,556 | 79,322 | 62.49 ms | 2.38M |
Deserve emits lifecycle events (route, view, worker, request, session, csrf, 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 '@app/index.ts'
const router = new Router({ routesDir: 'benchmark/routes', viewsDir: '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) | 147,434 | 148,492 | 148,949 | 148,292 | 33.21 ms |
| On (empty listener) | 115,880 | 118,030 | 114,016 | 115,975 | 42.61 ms |
Attaching a listener drops throughput to about 78% of the no-listener baseline and raises average latency from 33 ms to 43 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 ~148k.
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, worker:timeout, worker:crash, session:invalid,
csrf:rule-error, and process:error.
benchmark/main.ts: server entry (non-worker)benchmark/main-worker.ts: server entry (worker-enabled)benchmark/main-log.ts: server entry (one empty event listener)benchmark/routes/*.ts: route handlersbenchmark/views/*.dve: DVE templates