What Breaks When You Port FastAPI to Pyodide on Cloudflare Workers

What Breaks When You Port FastAPI to Pyodide on Cloudflare Workers

pr0h0
fastapipyodidecloudflare-workerspythonserverless
AI Usage (87%)

Introduction: The FastAPI-to-Pyodide Port That Looks Like a Copy-Paste Job

The pitch writes itself: Cloudflare ships Python Workers GA, you already have a FastAPI service, so you move main.py, add a fetch handler, and ship to the edge. The summary I'm working from — a 22 September 2026 item describing Python Workers GA "for AI and enterprise applications" — says little about the runtime. This post is about what actually happens when you port FastAPI to Pyodide on Cloudflare Workers, and exactly where it breaks.

My position: FastAPI does not port to Workers. It rewrites at the edges and survives in the middle. Routing, dependency injection, request validation, and pure-Python business logic move over with modest edits. The runtime assumptions underneath them — an ASGI server, lifespan events, threads, sockets, a writable filesystem, a process that stays alive between requests — do not exist in the shape FastAPI expects.

Four classes of breakage came out of reading through the runtime model. Below: what each one looks like, the adapter boundary you have to write yourself, and which parts of this come from Cloudflare's docs versus which are inference.

What Pyodide Actually Runs on Cloudflare Workers

CPython compiled to wasm32, not a drop-in Python runtime

Python Workers run on Pyodide — CPython compiled to wasm32-emscripten, plus a JavaScript bridge and the micropip wheel installer. That sentence explains most of the porting pain. You're not running "Python on Linux" inside a container; you're running one specific CPython build against one specific ABI, and only packages compiled for that target load.

The Workers layer on top: fetch handler, bindings, isolate lifecycle

Per the Python Workers docs, the entry point is a handler, not a server:

from workers import Response

async def on_fetch(request, env):
    return Response("ok")

No listening socket, no uvicorn, no port. The platform calls your function. Config arrives through the env argument (bindings), not as process environment variables. The code runs inside an isolate the platform reuses opportunistically and evicts without notice — so module-level state sometimes persists between requests, and sometimes doesn't.

Breakage 1 — ASGI Lifespan Has No Home in an Isolate

Startup and shutdown hooks that never fire the way FastAPI is written

Starlette's lifespan protocol fires startup once when the server begins serving and shutdown once when it stops. There is no serving loop here and no stop. Whatever your @app.on_event("startup") handler built — a connection pool, a loaded model, a config cache — has no guaranteed moment to run.

The failure is quiet, which is worse. Nothing raises. The global just never gets populated, and the first request hits AttributeError: 'NoneType' object has no attribute 'execute'.

One event loop per isolate and module state that survives between requests

Each isolate has its own event loop and its own copy of module state. Two concurrent requests can land in the same isolate and share that state; two sequential requests can land in different isolates and share nothing. Cloudflare's own guidance treats globals as a cache, not a store.

Working pattern: lazy initialization behind a cached resource, or move durable state to a Durable Object

The pattern that works is lazy init with an idempotent guard, so the cold path and the warm path are the same code:

_state = None

async def get_state(env):
    global _state
    if _state is None:
        _state = await build_state(env)  # pool, client, model handle
    return _state

Anything that must outlive an isolate — counters, sessions, per-tenant caches — belongs in a Durable Object, a KV/R2 binding, or an external store, not in a module global.

Breakage 2 — C Extensions and the Wheel Gap

Why pure-Python dependencies port and compiled ones usually do not

Pure-Python wheels are just files — micropip installs them. Wheels with compiled extensions need a build for the Emscripten target. The Pyodide project ships a set of those and publishes the list, but the list is version-specific.

The usual suspects in a FastAPI stack on Pyodide

  • pydantic-core — Rust, the engine under Pydantic v2 and therefore under modern FastAPI validation. This depends on whether your pinned Pyodide version has a wheel. If not, pinning pydantic<2 gets you FastAPI's older pure-Python validation path — slower, but it imports.
  • uvloop and httptools — irrelevant once you stop running a server. Delete them.
  • cryptography — commonly present in Pyodide's wheel set; verify, don't assume.
  • Database drivers (asyncpg, psycopg, aiomysql) — these need sockets (see breakage 4), so the wheel question is moot.

Reading the import error and triaging which dependency is actually missing

Run the install and read the failure shape:


await micropip.install("fastapi", "pydantic")
ValueError: Can't find a pure Python 3 wheel for: 'pydantic-core'
See: https://pyodide.org/en/stable/usage/faq.html#why-can-t-micropip-find-a-pure-python-wheel-for-a-package

Triage order: does a wheel exist for this Pyodide version → is there a pure-Python fallback or older major → can you delete the dependency instead. The exact error text is version-dependent; the decision tree is what transfers.

Breakage 3 — Blocking Code, Threads, and Background Work

def endpoints versus async def under a single-threaded event loop

FastAPI documents that plain def endpoints are run in a threadpool. Pyodide's threading support is limited, and in the common builds you shouldn't count on starting a real thread. Neither outcome is good: the runtime either raises when it tries to dispatch to a worker thread, or the endpoint runs on the event loop and blocks every other request in the isolate until it returns. Either way, def endpoints are out. Convert them to async def and delete the blocking call — a synchronous database driver, a CPU-heavy loop, time.sleep.

BackgroundTasks and asyncio.create_task when the response ends the isolate

FastAPI's BackgroundTasks run in-process after the response is sent. On Workers, once the response is returned the isolate can be frozen or evicted; work still in flight is best-effort. Use ctx.waitUntil(...) (see the ExecutionContext docs) for short tails, and a Queue for anything you'd be upset to lose.

No threads, no multiprocessing, no subprocess — what replaces them

You wantWorkers/Pyodide substitute
Thread pool for blocking I/Oasync client that speaks fetch, or offload to a Durable Object
multiprocessing for CPU workanother Worker/Queue consumer, or a Container
subprocessnothing — rewrite the call in-process or as an HTTP hop
Cron-style jobsCron Triggers, Durable Object alarms

Breakage 4 — Sockets, Files, and Configuration

Outbound HTTP has to go through the Workers fetch binding, not asyncio sockets

requests, httpx with socket transports, and anything built on asyncio.open_connection assume real sockets. Workers provide an HTTP client through the fetch API. The reliable path in Python Workers is the platform's fetch binding (or js.fetch through the JS bridge); check the docs for the currently recommended wrapper before you wire up a client library.

No raw sockets, no localhost sidecars, no arbitrary filesystem paths

Emscripten's filesystem is in-memory. /tmp, ~/.cache, and SQLite-on-disk don't exist as you expect, and anything you write is gone when the isolate is evicted. localhost sidecars and 127.0.0.1 ports are meaningless — there is no host. Outbound TCP exists on Workers through cloudflare:sockets, but bridging that into Python's socket module is something you'd have to build; I have not seen it shipped.

Secrets and config arrive as bindings, not as os.environ reads

os.environ.get("DATABASE_URL") returns nothing unless an adapter populated it. Read from the env argument, or set os.environ from env at the top of your handler if a library insists on reading the environment. Prefer the binding.

A Minimal Reproduction and What the Logs Actually Say

A small FastAPI app, the adapter boundary, and the failure matrix

To get FastAPI to respond at all, you write the ASGI adapter yourself. This is the boundary, and it's illustrative, not production-ready:

asgi_adapter.py
from workers import Response
from main import app  # the FastAPI instance

def build_scope(request, url):
  return {
      "type": "http",
      "asgi": {"version": "3.0", "spec_version": "2.3"},
      "http_version": "1.1",
      "method": request.method,
      "scheme": url.protocol.replace(":", ""),
      "path": url.pathname,
      "raw_path": url.pathname.encode(),
      "query_string": url.search.encode().lstrip(b"?"),
      "headers": [[k.encode(), v.encode()] for k, v in request.headers],
      "client": [None, 0],
      "server": [url.hostname, 443],
  }

async def on_fetch(request, env):
  body = await request.bytes()
  inbox = [{"type": "http.request", "body": body, "more_body": False}]
  status, headers, chunks = {"code": 500}, [], []

  async def receive():
      return inbox.pop(0) if inbox else {"type": "http.disconnect"}

  async def send(message):
      if message["type"] == "http.response.start":
          status["code"] = message["status"]
          headers[:] = message.get("headers", [])
      elif message["type"] == "http.response.body":
          chunks.append(message.get("body", b""))

  from urllib.parse import urlparse
  scope = build_scope(request, urlparse(request.url))
  await app(scope, receive, send)   # no lifespan call, by design
  return Response(
      b"".join(chunks),
      status=status["code"],
      headers={k.decode(): v.decode() for k, v in headers},
  )

Two things stand out. First, app(scope, receive, send) is called per request — no lifespan, which is exactly why lazy init is mandatory. Second, flattening headers into a dict silently drops duplicates like set-cookie; fix that before you ship auth.

BreakageError signature to expectEvidenceWorkaround
Lifespan never firesAttributeError: 'NoneType' on first requestStarlette lifespan modelLazy init, Durable Object
uvicorn startSocket-layer failure from create_serverInferred — verifyDrop the server, call the ASGI app
pydantic-coreCan't find a pure Python 3 wheelVersion-dependentPin pydantic<2 or vendor a wheel
Blocking def endpointThread-start error, or stalled isolatePyodide threading limits — verifyConvert to async def
BackgroundTasksSilent loss after responseWorkers isolate lifecyclectx.waitUntil, Queues
os.environEmpty valuesWorkers binding modelRead env from the handler
Filesystem writesData gone between requestsPyodide MEMFSR2/KV bindings
128 MB ceilingOOM under loadWorkers limitsTrim deps, stream instead of buffering

What Survives the Port — and the Shape That Actually Works

Keep routing, validation, and pure-Python business logic; rewrite the edges

What survives intact: the router, path/query parameter parsing, dependency injection, response models, OpenAPI generation, and your domain logic — as long as validation runs on a pure-Python engine. What gets rewritten: the server, the lifespan, every I/O client, configuration loading, background execution, and anything touching disk.

The shape that actually works is a thin handler that authenticates from env, resolves state lazily, calls into your existing service layer, and returns a Response. FastAPI is optional in that picture; if you keep it, keep it as a router and validator, not as an application server.

What Is Confirmed and What Is Not

Confirmed: the discovery source (a 22 September 2026 news summary) states Cloudflare made Python Workers generally available for AI and enterprise applications; Cloudflare's docs describe Python Workers as Pyodide-based with an on_fetch(request, env) handler; Workers isolates have a documented 128 MB memory limit; outbound TCP via cloudflare:sockets exists.

Not confirmed: the full GA feature list — the summary I have does not enumerate it, and I could not verify a primary Cloudflare link at the time of writing. Whether FastAPI is supported officially: the docs describe a bare handler model, so treat "no" as my reading, not a policy statement. Whether pydantic-core has a wasm wheel for your Pyodide version. Whether any DB driver bridge to cloudflare:sockets exists today. Whether def endpoints raise or stall — I'd expect a thread-start failure, but I have not run it on GA.

What would confirm these: a micropip.install("pydantic") on your pinned Pyodide version; a def endpoint with a time.sleep(2) and two concurrent requests; a Python Worker calling asyncpg.connect against Hyperdrive.

Conclusion: Port the Logic, Rewrite the Runtime Assumptions

Python Workers GA is genuinely useful for request-scoped Python at the edge. It is not a place to drop a server application unchanged. Port the router, the validation, and the business logic. Then rewrite — not adapt — the four things FastAPI assumes: a server, a startup phase, threads, and sockets. Budget accordingly, and test on the real runtime before you promise a migration date.

Further Reading

Share this post

More posts

Comments