Orisha on Vercel: The Whole Site as One Module, Served from the Edge

· 11 min read

The page you are reading was compiled into a binary. Not read from a directory next to a server — embedded: every file on the site turned into a pre-built HTTP response at compile time, gzip, ETag and cache headers included. At runtime the program never touches a disk, because there is nothing on the disk to touch.

This is the whole story of putting that module in the place that bills you per call — Vercel — with the honest numbers: how fast it actually is, what it actually costs, and whether you would move an existing SvelteKit site to it.

The module

The whole site is one WebAssembly module. It answers HTTP as a pure function: a host writes a request into it, calls one function, reads a complete response out. No socket, no event loop, no listener — the platform owns the reading-half; the module keeps only the half that turns a path into a page.

std/build:config {
    "target": "wasm32-freestanding"
}

orisha:static(name: "site", root: "../../../korulang_org/build", fallback: "200.html")

orisha:handler = orisha:static-router(name: "site")

Every static route is a complete, pre-rendered response — status line, headers, gzip, ETag — baked at compile time, sitting in the module’s own memory. Answering a request is: parse the path, hand back a pointer. Nothing allocated, nothing copied.

The reactor — the wasm host seam, the exported request/response windows, the single handle function — is generated by the koru/vercel library. The user-facing surface is pure Koru; the Zig half is plumbing you never write. The site’s own release path is a thin source script that wraps that builder — bake the static build, compile the reactor with the routing/backend/link config, deploy, and byte-verify the live artifact:

# scripts/publish-orisha.mjs — the site's real publish path (abridged)
STATIC_BUILD=1 bun run build:local                          # 1. bake the static site
koru-vercel build Stage --root build --name site \
  --routes /playground,/learn --backend … --dynamic /api/,/blog/drafts   # 2. reactor + deploy dir
koru-vercel deploy Stage                                     # 3. deploy to Vercel
# 4. verify the live slug is a real page, not the SPA shell

The koru-vercel commands are the machinery; the site keeps a source script (in the same JavaScript toolchain as its generators) that holds the per-site config and runs the verify step. Publishing is recompiling, and the verify step exists because the failure mode — an empty shell serving where a page should be — looks exactly like a working site.

The honest benchmark: how fast is it?

The honest comparison is the same content, on the same machine, no network and no CDN in the way — because a CDN would equalize user-facing latency for both sides. So: native Orisha serving the 65 MB adapter-static build of this site, vs SvelteKit’s own production preview server serving the identical build, over loopback, wrk -t2 -c50, interleaved rounds, byte-verified.

Same-content gate (passed). Both servers return identical content — same visible text, same gzipped wire bytes (7,174 B for the post, 13,356 B for the homepage). This is not comparing different pages.

Time to first byte (n=40, interleaved):

pageOrishaSvelteKit preview
post0.33 ms p501.13 ms p50
home0.32 ms p501.47 ms p50

Throughput (wrk):

runOrishaSvelteKit preview
1152,001 req/s (1.04 GB/s)12,077 req/s (249 MB/s)
2148,936 req/s (1.02 GB/s)12,256 req/s (253 MB/s)

So as a server, Orisha is ~4× faster first byte and ~12× more requests per second than a stock SvelteKit static server, single worker, same bytes out. Binary size: ~23 MB, statically linked, no Node runtime, and the same source runs natively, as a unikernel with no operating system, or as this wasm module.

The honest caveat: those are loopback, server-to-server numbers. For a real user behind a CDN, both sides are edge-cached and user-facing latency is dominated by the network — the win shows where you own the serving edge, not in a browser test against Vercel.

The trap: an ETag is not cacheability

On Vercel there is no socket, so the module runs as a serverless function. That cost a function invocation per request — because the baked responses carried a strong ETag and no Cache-Control, and Vercel, left to its own devices, injects its default for a function response:

Cache-Control: public, max-age=0, must-revalidate

must-revalidate means the CDN may not serve a cached copy without going back to origin. So every request — the HTML page, a 12 KB logo, every hashed asset — revalidated through the wasm function. The module answers in microseconds, but Vercel meters functions per invocation (and at ~20 µs a request, active CPU is a rounding error) — so the answer speed does not make the call cheap. Every hit is still one payment. The ETag bought a cheap 304 for browsers; it bought nothing for the CDN, where the cost was.

The fix: tell the shared cache it may hold the answer

The baked content cannot change without recompiling, so it is safe to cache. The reactor now emits an explicit policy:

# pages (HTML etc.)
Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=86400

# hashed /_app assets — their URLs change when the content does
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable

s-maxage is the address the shared cache reads. Vercel’s edge caches a function response that carries it, then strips s-maxage/stale-while-revalidate from the client-facing header, so the browser still sees max-age=0 and revalidates via the ETag. Deploys propagate on the next visit. The hashed assets get a year of immutable because their filenames fingerprint their content.

Proof, on the live site — the first request is a MISS, the second a HIT:

# request 1:  x-vercel-cache: MISS
# request 2:  x-vercel-cache: HIT

# hashed favicon: same pattern — a 12 KB logo has no business running a server

The honest numbers: what it costs

Vercel’s current (Fluid compute) pricing, region iad1 (where this site runs):

ResourceHobby free / monthOn-demand
Function invocations1M$0.60 / million
Active CPU4 CPU-hr$0.128 / CPU-hr
Provisioned memory360 GB-hr$0.0106 / GB-hr

A warm request costs ~20 µs of CPU — 1M requests ≈ $0.001, negligible. Invocation count is the only thing that scales. And it scales with requests, not with work:

  • Before the cache fix: one invocation per request, HTML and every asset. A page view with nine assets is ten invocations. A million page views is tens of millions of invocations.
  • After the fix: one invocation per unique path per cache lifetime. Repeat hits — the overwhelming majority of any content site’s traffic — are served by the CDN for free; the wasm function only answers the misses it has not seen recently.

So the difference is the shape of the bill: linear in traffic, or asymptotic to the number of pages you have. On a large site that is the difference between “grows with every request” and “a rounding error.”

For honesty, our own site is small — Vercel Web Analytics shows ~120 page views/day, ~3,700/month — so korulang.org sits comfortably inside the free tier either way. The numbers above are the architectural case at real scale, which is the only case worth reasoning about for a server.

Would you move an existing SvelteKit app? The honest answer

This is the question that matters, and the honest answer is “it depends entirely on where you serve it.”

Your situationOrisha / koru-vercelVerdict
Static site on Vercel, already prerenderededge-cached, ~$0No win. Both sides are cached at the edge and both cost ~nothing. Switching adds friction for no gain. Do not move for cost.
Static content on your own box / VPS / unikernel / your own edge23 MB static binary, no Node runtime, ~150k req/s single worker, runs with no OSReal win. The same prerendered output SvelteKit gives you, served by compiled code that fits anywhere and out-serves a Node runtime by an order of magnitude.
Real server logic — SSR, forms, auth, dataOrisha serves the static shell; the dynamic surface still needs a backend behind itComplement, not replacement. Orisha is the server half, never the framework. Your Node/Convex/whatever stays for what needs a computer.

So the honest pitch is narrow and sharp: if you have a static build, you already did the work — Orisha stops paying the platform to serve it. On Vercel for a static site that buys nothing today, because the CDN already holds both sides. Off Vercel — your own hardware, a $5 VPS, a unikernel — it buys a server that is ~12× faster, ~23 MB, and needs no runtime at all.

There is also a less glamorous but real argument: the pipeline verifies what it ships. Baked routes mean errors surface at compile time, and the publish step byte-verifies the live artifact against the build — the check that caught every one of the “serves 200 OK while being wrong” defects that only a browser could see.

The shape of the thing

The unikernel said a web server can run on no operating system. This says the server part can disappear altogether. The same answer function — bytes in, complete HTTP out — runs unchanged whether the platform is a laptop, a machine with no OS, or a serverless function call.

And the thing the platform adds is not an enemy: it is a cache. The module’s whole design — every response pre-built, byte-for-byte, at compile time — is exactly the shape that should be served from the edge. The only mistake was forgetting to say so in a header. One s-maxage, and the fastest static server we have stops paying the platform for every request.