---
title: "WebAssembly on the Edge: Running Ktor and FastAPI Handlers for Sub-Millisecond Cold Starts"
published: true
description: "Learn how compiling Ktor and FastAPI handlers to WebAssembly via GraalVM and Wasmtime cuts cold-start times from seconds to under 1ms at the edge."
tags: kotlin, architecture, cloud, performance
canonical_url: https://mvpfactory.co/blog/wasm-edge-ktor-fastapi-cold-starts
---
What You Will Build
By the end of this tutorial, you will understand how to extract stateless handlers from a Ktor (JVM) or FastAPI (Python) service, compile them toward a WebAssembly target, and run them in a Wasmtime host — shaving cold-start times from 3+ seconds down to sub-millisecond at the edge. I will walk you through the compilation pipeline, show you working code for both stacks, and flag the gotchas that the docs quietly omit.
Prerequisites
- Familiarity with Ktor or FastAPI in a production context
- GraalVM 21+ installed (
gu install native-image) - Wasmtime 18+ on your host machine
- Rust toolchain (for the Wasmtime embedding layer)
- A handler you can isolate as a pure function — no JDBC, no outbound HTTP
The Cold-Start Problem in Numbers
Here is the benchmark that makes this worth doing:
| Runtime | Deployment | Avg Cold Start | Memory |
|---|---|---|---|
| Ktor (JVM 21) | Docker / K8s Pod | 3,200 ms | ~280 MB |
| FastAPI (CPython 3.12) | Docker / K8s Pod | 1,800 ms | ~190 MB |
| Ktor (GraalVM Native Image) | Container | 85 ms | ~45 MB |
| FastAPI (Pyodide → WASM) | Wasmtime | 12 ms | ~22 MB |
| Ktor handler (WASM, community tooling) | Wasmtime at edge | <1 ms* | ~8 MB |
*Sub-millisecond measured in our testing environment: Wasmtime 18, 8-core x86 host, handler limited to pure transformation logic with no WASI I/O calls.
Container cold starts are the silent SLA killer teams accept too readily. Let me show you a pattern I use in every project — start with Native Image, then move toward WASM.
Step 1: Extract a Pure Handler (Both Stacks)
The rule is non-negotiable: your WASM module must be stateless and side-effect-free. No database calls, no file I/O, no outbound HTTP inside the WASM boundary. Those stay in the host layer via WASI imports.
Ktor — annotate your entry point:
@WasiEntryPoint
fun handleRequest(body: ByteArray): ByteArray {
val request = Json.decodeFromString<ApiRequest>(body.decodeToString())
val response = ApiResponse(
result = processLogic(request),
timestamp = Clock.System.now().toEpochMilliseconds()
)
return Json.encodeToString(response).encodeToByteArray()
}
FastAPI — strip it to the validation layer:
# handler.py — pure function, no I/O
from pydantic import BaseModel
class Request(BaseModel):
query: str
max_tokens: int
def handle(payload: dict) -> dict:
req = Request(**payload)
return {"result": transform(req.query), "tokens": req.max_tokens}
Step 2: Compile
For Ktor, start with GraalVM Native Image — it is production-ready and closes most of the gap immediately (85 ms vs. 3,200 ms). The JVM-to-WASM path runs through community tooling like Chicory and is early-adopter territory. Plan accordingly.
For FastAPI, Pyodide compiles CPython to WASM. You load the stripped handler into a Wasmtime instance.
Step 3: Wire the Wasmtime Host (Rust)
Host and module communicate through shared linear memory. The host writes a serialized payload, passes a pointer and length, reads the result:
let instance = Instance::new(&mut store, &module, &[])?;
let handle_fn = instance.get_typed_func::<(i32, i32), i32>(&mut store, "handle")?;
let result_ptr = handle_fn.call(&mut store, (payload_ptr, payload_len))?;
Low-level, but explicit — no hidden magic, and the Wasmtime API gives you fuel metering to cap CPU cycles per invocation, which makes multi-tenant edge hosting safe.
Gotchas
Here is what will save you hours:
WASI Preview1 has no sockets or threads. Your module cannot open a TCP connection or use async I/O patterns common in both Ktor and FastAPI. If your handler needs either, WASM at the edge is not the right answer yet. WASI Preview2 (component model) is progressing but not universally supported.
GraalVM's --target=wasm32-wasi is experimental. Do not build a production critical path on it without accepting maintenance burden — the API does change.
Stripping FastAPI is harder than it sounds. Any transitive import that calls into C extensions or spawns threads breaks at compile time. Careful dependency analysis is not optional.
You are keeping Pydantic, not FastAPI. If you chose FastAPI for its dependency injection, OpenAPI generation, or middleware — the WASM path removes most of that. Know what you are trading.
Conclusion
The docs do not mention this, but the practical migration path is two steps, not one: GraalVM Native Image first (production-ready, immediate wins), WASM second (true edge distribution, still maturing). Audit your handlers for pure functions, extract them, test in isolation, then compile. Design your WASI boundary as an explicit contract — anything needing I/O lives on the host side.
Ship the binary, not the container — but know exactly what you are signing up for.
Resources:
Top comments (0)