Server framework — Hono for backend perspectives
Decision date: 2026-05-16 Decision owner: Julian, after architecture discussion Status: Adopted; migration in progress
TL;DR
Pure-REST and pure-RPC perspectives (api, mcp; on the jobcache side these
have since been folded into the jobcache-interface Hono process via
host-based routing) run on Hono. Human-facing perspectives that render
HTML + SSR + forms (ui, status, ops, qa, and the SvelteKit UI inside
jobcache-interface) stay on SvelteKit. Wiki perspectives are moving
to LeafWiki (separate decision).
This document records why Hono over SvelteKit, itty-router, worktop, or a raw fetch handler — and what "MCP as a deduction of the API" means.
Why not stay on SvelteKit for everything?
SvelteKit is excellent at what it does: SPA + SSR + hydration + forms + asset pipeline + file-based routing. Every one of those features is useful in ui/, status/, ops/, qa/, jobcache-ui. None of them is useful in api/ or the MCP workers, which are pure HTTP/JSON-RPC services.
The api worker on SK was carrying ~4MB of unused runtime per isolate. That's not a quota or pricing issue — but it's wrong shape, and shape compounds:
- Cold start ~30–50ms instead of ~5–15ms
- Memory footprint per isolate ~30–40MB instead of ~5–10MB
- Tied to SvelteKit's adapter ecosystem; loses portability to other web-standards runtimes
- File-based routing fights against operation-derived MCP tools
For a backbone API that will accrete users, observability, rate limiting, JWT auth, OpenAPI, distributed tracing over a 3–10 year horizon, the right shape is a framework built for the edge runtime.
Alternatives considered
| Framework | Bundle | Cold start | Per-req cost | Middleware ecosystem | Verdict |
|---|---|---|---|---|---|
| SvelteKit | ~4MB | 30–50ms | OK | thin (built for full-stack apps, not APIs) | Excellent for UI workers, wrong shape for pure REST |
| Hono | ~14KB | 5–10ms | ~5μs (trie router) | excellent (JWT, CORS, Zod, OpenAPI, observability all official) | Picked |
| itty-router | ~1.5KB | <5ms | ~30μs (linear regex) | minimal (you write your own) | Strong, defensible, lost on middleware-growth |
| worktop | ~2KB | <5ms | ~10μs | weak ecosystem | Velocity has slowed; not picking |
Raw fetch(req, env) |
smallest | smallest | unbeatable | none (you write everything) | Fine for tiny surfaces (mcp/'s original ~3 endpoints); becomes maintenance burden as API grows |
| tRPC | small | fast | fast | excellent for TS-to-TS | Locks API to TS-only callers — kills MCP/curl/agent consumers |
| NestJS | huge | slow | slow | huge | Not edge-native; assumes Node |
Why bundle size is the wrong metric to optimize on alone
Bundle size affects cold start and memory footprint, not per-request CPU cost. Per-request cost is dominated by router algorithm, middleware overhead, and your own handler logic. Hono is ~10× the bundle of itty-router but ~1.8× the throughput per isolate, because its trie-based router is genuinely faster than itty-router's linear regex scan. Size and speed are loosely correlated, not identical.
For our load, both Hono and itty-router are wildly overpowered for the foreseeable future. The deciding factor isn't raw speed — it's how the framework's overhead grows as the API accretes features:
- Hono per-request cost stays constant as routes/middleware grow (trie routing, O(log n) middleware composition)
- itty-router per-request cost grows linearly with routes (regex scan per request)
- Hono's ecosystem makes new middleware "install + 1 line"; itty-router is "write + maintain"
For a backbone API that's expected to grow, Hono wins on growth slope, not on starting position.
What Hono gets us, concretely
Web-standards Request/Response. Same primitive shape as CF Workers, Deno, Bun, Vercel Edge, AWS Lambda@Edge, Node 20+. If we ever leave Cloudflare, the api worker ports with zero rewrite.
Middleware as a first-class pattern. CORS, auth, rate limiting, logging, Zod validation, OpenAPI generation, tracing — all live as composable
app.use()calls instead of glue code in each handler. We already hadrequireAdmin,requireAdminForPage, andcheckLoginRateLimithand-rolled in@cv/perspective-sharedbecause SK doesn't have a real middleware story. Hono middleware replaces all of those with installable packages.Zod-first validation. We already use Zod in
@cv/schemas.@hono/zod-validatorreuses the same schemas to validate every endpoint's body/query/params/headers at the edge before handler logic runs. Malformed input gets caught with a typed error response, no per-handler validation code.OpenAPI auto-generation.
@hono/zod-openapiwalks the route table and emits an OpenAPI 3 spec. Free API documentation forwiki.careervector.corbet.ch. MCP tool definitions can be derived from the same source. AI agents discover endpoints without humans documenting them.Future-proof for observability. Axiom, Workers Analytics Engine, and a local
/metricspull endpoint all slot in as first-class Hono middleware. We don't have to write a logging shim or wonder where to inject metrics.
Why MCP becomes a "deduction of the API"
The careervector-mcp worker and the MCP surface inside jobcache-interface
expose tools (list_jobs, get_workspace_summary, search_postings,
etc.) that fundamentally call the same operations as the corresponding
API endpoints. Under the prior architecture, the operations were
duplicated:
API: /api/workspaces/[id]/jobs/+server.ts calls workspaceOps.listJobs()
MCP: tools/list_jobs in src/tools.ts calls workspaceOps.listJobs()
(or worse: re-derives the query)
Under the Hono architecture, operations live in @cv/server (or a new
@cv/operations) as pure typed functions. Both API and MCP are thin
transport wrappers around the same operation registry:
Operation: listJobs(env, workspaceId, filters): Promise<Job[]>
API: app.get('/workspaces/:id/jobs', async (c) => {
const jobs = await listJobs(c.env, c.req.param('id'), parseFilters(c));
return c.json({ jobs });
});
MCP: tools.list_jobs = {
handler: (args, ctx) => listJobs(ctx.env, args.workspaceId, args.filters),
schema: z.object({ workspaceId: z.string(), filters: FilterSchema })
};
Adding a new operation:
- Define it in
@cv/operationswith a Zod input schema. - Mount it on the API as a Hono route.
- The MCP tool registration picks it up automatically (or via a one-line
tools.foo = mcpToolFor(opFoo)).
The wiki's API reference page reads the same registry to render docs.
What stays on SvelteKit
ui/— full SPA, Svelte components, Yjs realtime, hydrationstatus/,ops/,qa/(CV and JobCache) — HTML pages, login forms, session cookies, server-side renders. SK form actions are the right tool here.jobcache-ui/— SSR search page with form submission
These are pages, not APIs. SK is right for them and there's no benefit to migrating.
What moves to Hono
api/— full migration in progressmcp/(careervector-mcp) — rewrite on Hono as a thin wrapper around shared operationsjobcache/mcp/— same pattern
What's NOT changing
@cv/schemas— Zod schemas, framework-agnostic, used everywhere@cv/mutations— op catalog, framework-agnostic@cv/domain— business logic, framework-agnostic@cv/health— health snapshot types, framework-agnostic@cv/perspective-shared— admin auth + rate limit (used by SK perspective workers); stays as is since those workers stay on SK
The @cv/server lib is being refactored to take env directly instead
of App.Platform. This is the unlock — once helpers take env, they
work from Hono, SK, raw fetch handlers, any future runtime.
Migration plan
- Refactor
@cv/serverhelpers: signatures takeenv: ServerEnvinstead ofplatform: App.Platform | undefined. Update all callers in ui/ (SK, passesevent.platform!.env) and api/ (Hono, passesc.env). - Rewrite api routes as native Hono handlers grouped into sub-routers
(
api/src/routers/workspaces.ts,api/src/routers/llm.ts, etc.). Use@hono/zod-validatorfor input validation. - Drop the sk-adapter shim; drop SvelteKit + adapter-cloudflare deps;
drop svelte.config.js and vite.config.ts; wrangler compiles
src/worker.tsdirectly. - Lift operation logic out of api handlers into pure functions in
@cv/server(or new@cv/operations). - Rewrite mcp workers on Hono. Tool handlers call the operation functions directly.
- Deploy api and mcp workers under new versions; verify against the existing SK-based api worker before swapping the Custom Domain binding.
- Decommission the SK-era api artifacts.
What this decision does NOT cover
- The wiki perspective is moving to LeafWiki, decided separately. See the wiki ADR (TODO).
- Workers that need their own framework discussion (none currently planned).
- The web client's
apiUrl()helper continues to point atapi.careervector.corbet.ch— no client-side changes needed during the migration. As long as the api worker preserves response shapes, the web client doesn't know or care which framework served the call.