DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

WebAssembly on the Edge: Running Ktor and FastAPI Handlers in WASM Runtimes for Sub-Millisecond Cold Starts

---
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
---
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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))?;
Enter fullscreen mode Exit fullscreen mode

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)