SK vs Hono: careervector-api head-to-head

Status: decided; Date: 2026-05-16 Decision: Keep Hono. The latency wins are real and consistent. The bundle bloat is a fixable artifact of how the migration was executed, not an intrinsic Hono trait.

Why this study exists

After migrating careervector-api from SvelteKit (adapter-cloudflare + +server.ts) to native Hono, the production bundle came out bigger, not smaller:

Build Total upload Gzipped
Pre-migration (SK) 4,012 KiB 761 KiB
Post-migration (Hono native) 7,310 KiB 1,392 KiB

That contradicted the architectural pitch. We deployed both as separate Cloudflare Workers (careervector-api-bench-sk and careervector-api) and ran identical request loads against each to find out whether the runtime characteristics compensate.

Experiment scaffold: experiments/api-framework-comparison/{README.md, bench.ts, results/}.

Method

Both workers deployed with identical bindings (same careervector D1, same R2 bucket, same realtime service binding, same env vars). Only difference: the framework + bundled code.

Three probes:

Probe Path Hits Tells us
pure-compute /version Returns a JSON literal, no I/O Pure framework overhead
d1-error /chain?workspace=___garbage___ Small D1 read, hits 400 path Framework + minimal D1
d1-404 /workspaces/___garbage___ Workspace lookup, hits 404 path Framework + real D1 query

Sequential latency (60 requests warm per probe per target), then concurrent throughput (100 requests with parallelism 20 against pure-compute).

Ran three times in immediate succession to factor out CF edge-state noise.

Results

Sequential warm latency (ms, p50)

Probe Run 1 SK Run 1 Hono Run 2 SK Run 2 Hono Run 3 SK Run 3 Hono Mean SK Mean Hono Delta
pure-compute 45.3 36.3 47.5 42.0 43.4 36.2 45.4 38.2 −16%
d1-error 56.2 54.2 55.1 48.8 53.0 45.9 54.8 49.6 −9%
d1-404 67.6 67.6 63.9 58.2 84.1 60.7 71.9 62.2 −13%

p95 numbers tell the same story (Hono ~10–20% lower across all probes, all runs).

Hono is consistently 10–20% faster on warm sequential requests. The pure-compute delta (~7 ms p50) is the cleanest signal because there's no D1 round-trip in the way. The D1-bound probes show smaller deltas in absolute ms but similar percentage gain — the framework overhead reduction is real even when D1 dominates.

Concurrent throughput (req/s)

Run SK Hono Winner
1 180 147 SK +22%
2 195 207 Hono +6%
3 199 130 SK +53%

Inconclusive — too noisy to read. The throughput test fired 100 requests with 20-way parallelism. Variance between runs (50%+) drowns the framework-level signal. To get a real reading we'd need to:

  • Drive load from multiple geographic origins (current test runs from one machine in Zurich, so all traffic hits ZRH colo)
  • Sample sizes 10×+ larger (1000+ req per run)
  • Include a real workload mix (writes, AI calls, file I/O)

Not worth the cycles for this decision. The sequential latency signal is robust enough on its own.

Bundle size

SK Hono (initial) Hono (slim barrel) Δ vs SK
Upload total 4,012 KiB 7,310 KiB 4,511 KiB +12%
Gzipped 761 KiB 1,392 KiB 809 KiB +6%

The +82% initial regression turned out to be a @cv/domain barrel issue, not a Hono trait.

Root cause: lib/domain/src/index.ts was re-exporting every sibling module via export * from './foo', including ./typst-client, ./export, ./import, the measurement chain, and the SVG-page renderer. Those modules transitively pull xlsx (1.0 MB), jspdf (502 KB), html2canvas (403 KB), canvg (187 KB), jspdf-autotable (73 KB) — all browser-only code the api worker never executes. The Hono bundle picked them up via the barrel; the SK bundle had been masked by SvelteKit's adapter-cloudflare doing more aggressive route-level code-splitting before barrel resolution.

sideEffects: false on @cv/domain/package.json was not sufficient on its own — the modules have top-level state (let worker: Worker; let pending = new Map(); …) that esbuild's conservative tree-shaker counted as side-effecting even with the flag present.

Fix applied: drop browser-only re-exports from the barrel. Every caller already uses sub-paths (@cv/domain/typst-client, @cv/domain/export, etc.). The barrel now exposes server-safe surface only:

  • removed: ./typst-client, ./typst-escape, ./typst-fragments, ./typst-header, ./typst-subject, ./typst-serialize-cache, ./measure-source, ./page-geometry, ./svg-page, ./measure-cache, ./ruler-items, ./postprocess, ./node-to-typst, ./tree-section-view, ./tree-shape-diagnostics, ./export, ./import
  • kept: everything else (llm, ai, chain, db, scoring, lock, audit, …)

After fix: 4,511 KiB upload / 809 KiB gzip. Two test files in lib/domain/tests/ swapped to the sub-path import. No production callers needed changes. Web/realtime/mcp/jobcache typecheck clean.

The remaining +500 KiB / +50 KiB delta vs the SK baseline is mostly genuine framework footprint (Hono runtime + middleware) and a small amount of router code growth. Well inside the 10 MB CF Workers limit and now in cold-start parity with SK.

Why the latency win is real (not measurement noise)

The pure-compute delta (~7ms p50 reduction) showed up in all three runs with low p95/p99 variance. It's reproducible. The reasons it's real:

  1. No SSR/router machinery to traverse. SK's adapter-cloudflare runtime walks its own routing tree + middleware chain on every request. Hono's trie-based router does the same job in a few microseconds.
  2. Smaller hot-path object allocation. SK constructs a RequestEvent with cookies, fetch, locals, etc. on every request. Hono's Context is leaner.
  3. No SK runtime overhead for handler dispatch. SK has a generic load function machinery and content-negotiation that doesn't apply to pure API endpoints.

This is the framework-level overhead that gets paid once per request regardless of what your handler does. ~7ms per request is small in absolute terms but every request pays it.

What this means at scale

At our current single-user dev volume, ~7ms × few-hundred-req/day is invisible.

At "many users" scale:

  • Average user generates ~50 api calls per session
  • ~5 sessions per active user per day
  • → 250 req/user/day
  • Hono saves 7ms × 250 = ~1.75s of cumulative latency per active user per day
  • For 1000 active users: ~30 min/day of total latency saved across the user base
  • For 100k active users: ~5 hours/day of total saved latency

These aren't user-visible (each individual delta is small enough to be lost in network noise), but they translate to real CF CPU-time savings in aggregate and to lower p95s for users on slower networks where every ms compounds.

Verdict

Keep Hono. The latency win is consistent and architecturally honest (it comes from skipping SSR machinery we don't use). The bundle bloat is an implementation artifact of how the migration ran, not an intrinsic property of Hono — fixable with helper extraction in a follow-up pass.

The SK approach was acceptable. Hono is slightly better. The architectural arguments for Hono (web-standards portability, middleware ecosystem, OpenAPI generation, etc.) hold independent of this benchmark.

Follow-ups

  1. Helper-dedupe pass on api/src/routers/*.ts — found to be a red herring. The real bloat was the @cv/domain barrel re-exporting browser-only modules. Fixed; bundle now within 12% of SK baseline.
  2. Cold-start measurement — would need to script a 5-minute idle gap + first-request timing. Worth doing if cold starts become a complaint.
  3. Geographic load distribution — if/when we have users across regions, rerun the throughput test from multiple origins.

Reproducing this study

# 1. Both bench workers must be deployed (current setup):
#    - careervector-api (Hono, live, custom domain api.careervector.corbet.ch)
#    - careervector-api-bench-sk (workers.dev URL)
#    Deployment recipe lives in /tmp/api-sk-bench (git worktree at HEAD pre-Hono).

# 2. Run the bench:
bun run experiments/api-framework-comparison/bench.ts

# Output: console summary + raw samples to
# experiments/api-framework-comparison/results/<timestamp>.json

The bench script is intentionally simple — no jitter compensation, no statistical bootstrap. The signal is robust enough at this sample size that the simple summary captures the real story.

Source: wiki/content/studies/hono-vs-sk-api.md