Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

# cold-path measurement bundle output
.coldpath-out
# Cold-path measurement outputs
.coldpath-*
*.cpuprofile
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"bench:run": "bun run scripts/bench-queries.ts run",
"bench:inspect": "bun run scripts/bench-queries.ts inspect",
"bench:compare": "bun run scripts/bench-queries.ts compare",
"bench:startup-cpu": "bun run scripts/bench-startup-cpu.ts"
"bench:startup-cpu": "bun run scripts/bench-startup-cpu.ts",
"bench:service-bindings": "bun run scripts/bench-service-bindings.ts"
},
"dependencies": {
"@clerk/backend": "^2.30.1",
Expand Down
155 changes: 155 additions & 0 deletions apps/api/scripts/bench-service-bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"

const samples = Number(process.argv.find((arg) => arg.startsWith("--samples="))?.split("=")[1] ?? 250)
const port = Number(process.argv.find((arg) => arg.startsWith("--port="))?.split("=")[1] ?? 9897)
const benchDir = fileURLToPath(new URL("./service-binding-bench/", import.meta.url))
const wranglerCli = fileURLToPath(new URL("../node_modules/wrangler/wrangler-dist/cli.js", import.meta.url))
const baseUrl = `http://127.0.0.1:${port}`

const configs = ["router", "echo", "monolith", "telemetry"].flatMap((name) => [
"-c",
`${benchDir}${name}.wrangler.jsonc`,
])

// Run Wrangler's real CLI instead of its thin bin wrapper. The wrapper does
// not forward SIGTERM to the CLI process it spawns, which used to orphan both
// Wrangler and workerd after every benchmark run. A detached POSIX process
// group lets cleanup address the whole tree, including workerd descendants.
const wrangler = spawn("node", ["--no-warnings", wranglerCli, "dev", ...configs, "--port", String(port)], {
cwd: fileURLToPath(new URL("../", import.meta.url)),
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
})

if (wrangler.pid === undefined) throw new Error("wrangler did not start")
const wranglerPid = wrangler.pid

let wranglerLog = ""
for (const stream of [wrangler.stdout, wrangler.stderr]) {
stream.setEncoding("utf8")
stream.on("data", (chunk: string) => {
wranglerLog = `${wranglerLog}${chunk}`.slice(-16_000)
})
}

const isMissingProcess = (error: unknown): boolean =>
typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH"

const signalWranglerTree = (signal: NodeJS.Signals | 0): boolean => {
try {
if (process.platform === "win32") {
return signal === 0 ? wrangler.kill(0) : wrangler.kill(signal)
}
process.kill(-wranglerPid, signal)
return true
} catch (error) {
if (isMissingProcess(error)) return false
throw error
}
}

const waitForWranglerTreeExit = async (timeoutMs: number): Promise<boolean> => {
const deadline = Date.now() + timeoutMs
while (signalWranglerTree(0) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
return !signalWranglerTree(0)
}

let stopPromise: Promise<void> | undefined
const stop = (): Promise<void> =>
(stopPromise ??= (async () => {
if (!signalWranglerTree(0)) return
signalWranglerTree("SIGTERM")
if (await waitForWranglerTreeExit(5_000)) return
signalWranglerTree("SIGKILL")
if (!(await waitForWranglerTreeExit(1_000))) {
throw new Error(`wrangler process tree ${wranglerPid} did not stop`)
}
})())

const stopForSignal = (exitCode: number) => {
void stop().finally(() => process.exit(exitCode))
}
const onSigint = () => stopForSignal(130)
const onSigterm = () => stopForSignal(143)
process.once("SIGINT", onSigint)
process.once("SIGTERM", onSigterm)

const waitUntilReady = async () => {
const deadline = Date.now() + 45_000
while (Date.now() < deadline) {
if (wrangler.exitCode !== null) throw new Error(`wrangler exited early\n${wranglerLog}`)
try {
const response = await fetch(`${baseUrl}/ready`)
if (response.ok) return
} catch {
// The listener is not ready yet.
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`wrangler did not become ready\n${wranglerLog}`)
}

const percentile = (values: ReadonlyArray<number>, fraction: number): number => {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? 0
}

const summarize = (values: ReadonlyArray<number>) => ({
medianMs: percentile(values, 0.5),
p95Ms: percentile(values, 0.95),
minMs: Math.min(...values),
})

const samplePath = async (pathname: string): Promise<ReadonlyArray<number>> => {
const timings: Array<number> = []
for (let index = 0; index < samples; index++) {
const response = await fetch(`${baseUrl}${pathname}`)
if (!response.ok) throw new Error(`${pathname} returned ${response.status}`)
await response.arrayBuffer()
const timing = Number(response.headers.get("x-service-binding-ms"))
if (!Number.isFinite(timing)) throw new Error(`${pathname} omitted x-service-binding-ms`)
timings.push(timing)
}
return timings
}

try {
await waitUntilReady()
// Warm both isolates and the local HTTP connection before collecting samples.
await fetch(`${baseUrl}/direct`)
await fetch(`${baseUrl}/bound`)

const [direct, bound] = await Promise.all([samplePath("/direct"), samplePath("/bound")])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent sampling contaminates warm latency numbers

Promise.all([samplePath("/direct"), samplePath("/bound")]) runs both 250-request loops at once, so each stream measures latency while the other loads the same local server. Both the direct baseline and the service-binding number are inflated under doubled load, defeating the comparison the benchmark exists to make.

Suggested change
const [direct, bound] = await Promise.all([samplePath("/direct"), samplePath("/bound")])
const direct = await samplePath("/direct")
const bound = await samplePath("/bound")
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const monolith = await (await fetch(`${baseUrl}/probe/monolith`)).json()
const telemetry = await (await fetch(`${baseUrl}/probe/telemetry`)).json()
const monolithWarm = await (await fetch(`${baseUrl}/probe/monolith`)).json()
const telemetryWarm = await (await fetch(`${baseUrl}/probe/telemetry`)).json()

console.log(
JSON.stringify(
{
samples,
warmRouter: {
direct: summarize(direct),
serviceBinding: summarize(bound),
},
coldModuleGraph: { monolith, telemetryIsland: telemetry },
warmModuleGraph: { monolith: monolithWarm, telemetryIsland: telemetryWarm },
notes: [
"Service-binding timings are measured inside the router around binding.fetch().",
"Module timings measure dynamic import/evaluation, not database or warehouse I/O.",
"Local workerd numbers are relative evidence; production placement still needs a canary.",
],
},
null,
2,
),
)
} finally {
process.off("SIGINT", onSigint)
process.off("SIGTERM", onSigterm)
await stop()
}
95 changes: 95 additions & 0 deletions apps/api/scripts/service-binding-bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Service-bound route worker benchmark

Measured on 2026-08-23 with Bun 1.4.0, Wrangler 4.118.0, and local workerd on an Apple Silicon
development machine. These are relative local measurements, not production latency promises.

## What implementation actually exists

Hono does **not** create sub-workers for mounted routes. Its
[`HonoBase.route()` implementation](https://github.com/honojs/hono/blob/main/src/hono-base.ts)
copies a sub-application's handlers into the same in-process router.

The architecture that matches the remembered behavior is Cloudflare's
[HTTP service-binding API gateway pattern](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/http/):
a small public Worker selects a coarse route island and forwards the original `Request` to a private
Worker. Cloudflare documents service bindings as running on the same thread and server by default,
with no additional request charge, while each hop still counts toward the 32-Worker invocation
limit.

## Existing stack

Every request sample starts a fresh `wrangler dev` process, waits for the listener, and then sends
the first request. The workerd startup profile is measured separately by `wrangler check startup`.
Medians use five request runs (three for `/v2/services`).

| Metric | `main` | Lazy HTTP routes | Cold bootstrap |
| ----------------------------------------------- | -----------------------: | -----------------------: | -----------------------: |
| workerd active startup CPU | 114.2 ms | 117.8 ms | 30.7 ms |
| bundle (raw / gzip) | 12,895.31 / 2,544.05 KiB | 12,895.60 / 2,544.19 KiB | 12,832.11 / 2,534.29 KiB |
| desktop startup graph eval | 98 ms | 100 ms | 34 ms |
| desktop startup heap delta | 30.85 MB | 30.85 MB | 5.03 MB |
| cold `GET /health` request | 202.3 ms | 125.6 ms | 7.4 ms |
| cold unauthenticated `GET /v2/services` request | 207.3 ms | 129.0 ms | 217.5 ms |
| startup CPU + `/v2/services` local proxy | 321.5 ms | 246.8 ms | 248.2 ms |

Interpretation:

- Lazy endpoint compilation cuts the first real-route proxy by about 23%, while leaving upload-time
startup unchanged as designed.
- The cold-bootstrap layer removes 73% of active startup CPU, 84% of startup heap, and 96% of the
cold health-request time.
- The generated anticipated-error list moves domain-schema evaluation from startup to the first
real route. Consequently, request-only `/v2/services` time rises, but the combined local cold
proxy stays flat versus the lazy-route layer. It solves startup-budget and liveness risk; it does
not claim a further real-route cold win.

## Route-island experiment

Run:

```bash
bun run --cwd apps/api bench:service-bindings --samples=250
```

The router measures `binding.fetch()` inside workerd. The two probe Workers compare today's complete
HTTP/service module graph with the smallest useful telemetry-route module graph using current module
boundaries. Seven fresh-process runs produced:

| Graph | Cold module-evaluation samples | Median |
| ---------------- | ------------------------------------ | -----: |
| monolith | 153, 154, 150, 151, 157, 152, 152 ms | 152 ms |
| telemetry island | 107, 101, 101, 101, 103, 102, 102 ms | 102 ms |

The telemetry island reduces module evaluation by **32.9%**. A representative 250-request warm run
measured the service-binding call at **0 ms median / 1 ms p95** at workerd's timer resolution.

This is enough evidence to build a functional canary, not enough evidence to route production
traffic immediately. The probe evaluates real Maple modules, but intentionally excludes Effect
Layer acquisition, authentication results, database dialing, and warehouse I/O.

## Recommended rollout and acceptance gate

This benchmark is intentionally separate from the two runtime optimizations it measured. The
implementation sequence, topology, developer experience, production canary, and rollback design
live in [`docs/cloudflare-native-api.md`](../../../../docs/cloudflare-native-api.md).

Use coarse islands, not one Worker per endpoint. Start with the read-heavy v2 telemetry surface; it
has a coherent dependency graph and the benchmarked 34% module-evaluation reduction. Keep health and
preflight handling in the tiny public router. Let the target Worker own auth, request scope,
telemetry, and its per-invocation Hyperdrive connection. A shared "database Worker" would violate
Maple's request-bound connection lifecycle and add a hop without preserving a socket between
invocations.

Deploy the private target before the router, then canary a small share of telemetry requests and
compare against the monolith for at least one full traffic cycle. Ship the split only if all of these
hold:

1. Cold telemetry-route p50 and p95 improve by at least 15%.
2. Warm route p95 regresses by less than 2 ms.
3. Error rate, response bytes, CORS headers, auth envelopes, and tracing are equivalent.
4. Router plus target CPU is lower at cold p95 and no higher at warm p95.
5. Target startup, heap, compressed size, subrequests, and Worker-invocation depth remain inside
Cloudflare limits with at least 25% headroom.

If the functional canary misses either latency gate, keep the first two stack layers and drop the
route-worker layer; the benchmark harness remains useful for future module-boundary changes.
8 changes: 8 additions & 0 deletions apps/api/scripts/service-binding-bench/echo.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
fetch(): Response {
const startedAt = performance.now()
const response = new Response("ok")
response.headers.set("x-target-handler-ms", (performance.now() - startedAt).toFixed(4))
return response
},
}
7 changes: 7 additions & 0 deletions apps/api/scripts/service-binding-bench/echo.wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "maple-api-route-echo-bench",
"main": "./echo.worker.ts",
"compatibility_date": "2026-04-08",
"compatibility_flags": ["nodejs_compat"],
}
28 changes: 28 additions & 0 deletions apps/api/scripts/service-binding-bench/monolith-probe.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
let initialized = false
let initialization: Promise<void> | undefined

const initialize = (): Promise<void> => {
if (initialization !== undefined) return initialization
initialization = Promise.all([
import("../../src/runtime/service-graph"),
import("../../src/runtime/http-graph"),
import("../../src/platform/DatabasePgLive"),
import("../../src/platform/pg-connection-scope"),
]).then(() => undefined)
return initialization
}

export default {
async fetch(): Promise<Response> {
const wasInitialized = initialized
const startedAt = performance.now()
await initialize()
const moduleEvaluationMs = performance.now() - startedAt
initialized = true
return Response.json({
graph: "monolith",
cached: wasInitialized,
moduleEvaluationMs,
})
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "maple-api-route-monolith-bench",
"main": "./monolith-probe.worker.ts",
"compatibility_date": "2026-04-08",
"compatibility_flags": ["nodejs_compat"],
}
41 changes: 41 additions & 0 deletions apps/api/scripts/service-binding-bench/router.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
interface ServiceBinding {
fetch(request: Request): Promise<Response>
}

interface Env {
ECHO: ServiceBinding
MONOLITH: ServiceBinding
TELEMETRY: ServiceBinding
}

const withBindingTiming = async (service: ServiceBinding, request: Request): Promise<Response> => {
const startedAt = performance.now()
const response = await service.fetch(request)
const bindingMs = performance.now() - startedAt
const headers = new Headers(response.headers)
headers.set("x-service-binding-ms", bindingMs.toFixed(4))
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const pathname = new URL(request.url).pathname
switch (pathname) {
case "/ready":
case "/direct":
return new Response("ok", { headers: { "x-service-binding-ms": "0" } })
case "/bound":
return withBindingTiming(env.ECHO, request)
case "/probe/monolith":
return withBindingTiming(env.MONOLITH, request)
case "/probe/telemetry":
return withBindingTiming(env.TELEMETRY, request)
default:
return new Response("Not found", { status: 404 })
}
},
}
12 changes: 12 additions & 0 deletions apps/api/scripts/service-binding-bench/router.wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "maple-api-route-router-bench",
"main": "./router.worker.ts",
"compatibility_date": "2026-04-08",
"compatibility_flags": ["nodejs_compat"],
"services": [
{ "binding": "ECHO", "service": "maple-api-route-echo-bench" },
{ "binding": "MONOLITH", "service": "maple-api-route-monolith-bench" },
{ "binding": "TELEMETRY", "service": "maple-api-route-telemetry-bench" },
],
}
Loading
Loading