Porting a Real Python Backend to Cloudflare Workers: What Pyodide/WASM Actually Breaks

Porting a Real Python Backend to Cloudflare Workers: What Pyodide/WASM Actually Breaks

pr0h0
cloudflare-workerspythonpyodidewasmedge-computing
AI Usage (93%)

Introduction

Cloudflare's Python Workers are out of experimental and into general availability, and most coverage reads it as Python taking its place beside JavaScript and Rust at the edge (konsulteer.com, 2026-09-22; Technobezz, 2026-09-21). The engineering detail underneath the headline is sharper: this is Pyodide compiled to WebAssembly, not CPython on Linux, and that one sentence sorts the services that port from the ones that never will. Syntax is not the problem — async def, dataclasses, and type hints all survive. The porting cost sits in dependencies and runtime contracts: C extensions with no WASM build, a process model with no processes, and a JavaScript boundary that bills you per call. What follows is a hands-on account of moving a real Python backend across that boundary — what breaks, why, how to triage it before writing Worker code, and which workloads still make sense at the edge.

What GA Actually Means, and What the Summaries Do Not Say

Confirmed from the seed material: Cloudflare announced GA for Python Workers, the framing targets AI inference glue code and enterprise applications, and Python now sits alongside the existing JavaScript and Rust toolchains. That is the whole of what the secondary coverage establishes.

Inferred, and not stated in those news items: plan-level limits for Python Workers, whether package or bundle size ceilings differ from JS Workers, and whether Workers deployed on the experimental runtime must be re-deployed or are migrated. The seed does not answer any of it, and neither do the two news summaries.

Both news items are secondary coverage of an announcement. They are not technical authorities. Before you plan a port, read Cloudflare's Python Workers documentation and limits page directly, and check the Pyodide package index for the exact runtime version Workers pins. Treating a news summary as a compatibility list is how a port fails in week two.

The Pyodide/WASM Architecture That Decides What Ports

CPython is compiled to WebAssembly through Pyodide and executed inside a Workers isolate. There is no POSIX process, no glibc, no musl, and no Linux syscall surface. os.fork does not exist, subprocess has nothing to spawn, and ctypes cannot dlopen a .so built for x86-64 or arm64.

Everything downstream follows from that. Wheels must be built for WASM, not for a CPU. The filesystem is a memory-backed shim, so a "cache directory" is a variable with extra steps. Concurrency comes from the isolate's event loop and the bindings, not from OS threads.

The triage rule to carry through the rest of this post:

  • Pure Python (no C in the dependency tree) — ports, usually unchanged.
  • Native code (CPython C extensions, Rust/C++ wheels) — does not port unless a Pyodide build exists for your version.
  • Everything else — a judgment call about what to relocate behind an HTTP call instead of deleting.

Breakage Class 1 — C Extensions and Native Wheels

Why manylinux Wheels Cannot Load

Wheels on PyPI tagged manylinux are compiled against glibc for real CPU architectures. Pyodide needs a separately compiled build targeting wasm32-unknown-emscripten. Installing the PyPI wheel does not error at install time — resolution succeeds, and the failure surfaces at import, which means a naive port can look healthy until the first request touches that module. The reported pattern is a ModuleNotFoundError pointing at the compiled submodule (the _psycopg or _cffi_backend half of a package), not a build error. I did not reproduce that here; treat it as the documented shape of the failure rather than a transcript.

Data-Stack Casualties: pandas, lxml, cryptography, and Friends

Expect trouble from anything with a C core or a Cython-generated extension: pandas, lxml, orjson, cryptography, Pillow, and most database drivers. Some of these have Pyodide builds and some do not. "A Pyodide build exists" is also not the same as "it is in the Workers runtime at the version I need" — the runtime pins a specific Pyodide release, and a package's Pyodide build can lag its PyPI release by months.

The Database Driver Problem

psycopg2, asyncpg, and mysqlclient are native and will not load. The practical escape hatch is a pure-Python protocol implementation — pg8000 and pymysql speak the wire protocol from Python — running over Workers TCP sockets.

Untested here, and I am flagging it as such: connecting a deployed Worker to Postgres through a socket binding. What would confirm it: a deployed Worker with the socket binding enabled that completes a handshake, runs a parameterized SELECT 1, and reports handshake latency and per-query overhead separately. Until someone publishes those numbers, treat pure-Python drivers at the edge as plausible, not proven.

Dependency Triage as a Repeatable Step

Do this before writing Worker code, not after the first failed deploy.

python -m venv .triage && . .triage/bin/activate
pip install pipdeptree
pipdeptree --warn silence --json > tree.json

Then classify. The Pyodide distribution ships a pyodide-lock.json in its wheel directory listing every package with a WASM build; point your triage script at the lock file for the version your runtime pins, and mark each node in tree.json as pure-python, pyodide-built, or native-only. Decide keep / relocate / delete for every node in the last bucket. A single native leaf can poison the whole branch.

Breakage Class 2 — Runtime Behavior Differences

No Processes, No Threads, No Subprocess

Anything built on multiprocessing, a worker pool, or shelling out to ffmpeg or ImageMagick is structurally out. This is not a configuration problem to solve — there is no process to create. Media pipelines and CPU fan-out move to a container, and the Worker calls them.

Ephemeral, Effectively Read-Only Storage

A cache written to disk does not survive the isolate. State belongs in KV, R2, D1, Durable Objects, or an external store. That is a design constraint, not a footnote: anything that used a warm on-disk cache to avoid a cold computation now pays that computation on isolate startup — the cold-start case, precisely when you can least afford it.

Sockets, Pooling, and Connection Assumptions

Long-lived pools sized per process assume the process outlives the request. When the isolate is the process, database pools mostly become per-request connects. The question shifts from "how big is the pool" to "is a per-request handshake acceptable, or do I need a pooled proxy in front of the database?" For most read-heavy paths, the proxy wins.

Time, Memory, and CPU as Design Inputs

Workers enforces a CPU-time budget separate from wall-clock time, plus a memory ceiling. I am deliberately not quoting numbers: they vary by plan and change, and the current values belong to Cloudflare's limits page, not to a blog post. Treat them as constraints that push work off the request path — queues, Durable Objects, scheduled tasks — rather than as tuning knobs to shave.

Breakage Class 3 — The JavaScript Boundary Tax

Bindings Arrive as Proxied JS Objects

KV, R2, D1, and AI bindings are JavaScript APIs surfaced into Python as proxy objects. Every call crosses a marshalling boundary. Reading a string is cheap; passing a large object graph in and out is not.

Serialization Cost Shows Up Per Call, Not Per Request

A loop that reads 200 KV keys one at a time makes 200 boundary crossings and 200 round trips. Note that the KV binding has no batched get for a list of keys — so the fix is structural: write the aggregate once under a single key and read it once, or move the bulk read to R2 or D1 where you can express the work as one query. Batch, then measure; do not assume the boundary is free because Python looks synchronous.

Async Semantics and Event-Loop Ownership

The Python side does not own the event loop. Awaiting a binding returns control to the isolate; a call you forget to await hands you a proxy and defers the work in a way that is easy to miss. A minimal entrypoint, sized as an illustration rather than a benchmark:

src/entry.py
from workers import WorkerEntrypoint, Response, fetch

class Default(WorkerEntrypoint):
  async def fetch(self, request):
      tenant = request.headers.get("x-tenant") or "default"
      key = "config:" + tenant

      # One boundary crossing on the hot path.
      cached = await self.env.CONFIG_KV.get(key)
      if cached is None:
          upstream = await fetch("https://config.internal/tenants", method="POST")
          cached = await upstream.text()
          await self.env.CONFIG_KV.put(key, cached, expiration_ttl=60)

      return Response(cached, headers={"content-type": "application/json"})

The import surface here has shifted across releases (expiration_ttl naming in particular). Check the current docs rather than copying this verbatim.

A Worked Port: FastAPI Service Reduced to an Edge-Safe Core

Picture a FastAPI service doing tenant auth, request validation, a Postgres-backed read, and a nightly aggregation job. Here is the honest classification.

Package / componentClassDecision
pydantic (v2 core)native core, Pyodide build exists for pinned versionskeep, verify version
fastapi routing + validationpure Pythonkeep the validation, drop the ASGI server
httpxpure Python over fetchreplace with fetch
psycopg2native onlyrelocate behind HTTP
celery / in-process schedulerprocess assumptiondelete, replace with Cron Triggers
On-disk response cachefilesystem assumptionreplace with KV

What Survived

Pure-Python request validation, routing logic, token parsing, and outbound HTTP calls via fetch. This is the part teams underestimate: the glue code ports quickly, and the FastAPI-shaped thinking ports too — a Worker handler is another request/response function.

What Moved Behind an HTTP Call

The ORM layer, background jobs, and anything touching the native data stack stay in a container. The Worker becomes a thin, cache-aware front door that validates a token, checks KV, and either serves a cached payload or calls the container.

What Was Deleted

Local disk caches, in-process schedulers, and the assumption that the process is warm. Deleting these is the actual port. The Python that remains is smaller than what you started with.

Observed Results

I did not deploy a ported Worker for this post, so I am not going to print latency numbers that nobody measured. What is often assumed, and what would actually establish it:

MeasurementHow to get itValue here
Bundle size after vendoringnpx wrangler deploy --dry-run --outdir dist then du -sh distnot measured
Cold start vs equivalent JS Workerload generator after an idle period; compare first-request to warm p50not measured
p95 read pathsustained load against a cache-hit routenot measured

If you cannot produce these three numbers for your own service before a port, you do not yet know whether the port pays for itself.

Where Python Workers Genuinely Win

Short, stateless request handlers. AI inference glue sitting in front of a model binding. Webhook normalization — parse, validate, enqueue. And the case nobody puts in a press release: if the team is fluent in Python, the win is one language across the stack and no second deployment pipeline for a thin edge tier. That is a real operational saving, and it is the reason to port a request path even when the runtime is objectively slower than Rust.

Where Python Workers Are Still the Wrong Tool

Long-running compute, native data libraries, stateful connection pools, ffmpeg-style media pipelines, and anything that needs a writable filesystem. GA does not mean every Python service should move to the edge. GA means the runtime is supported and you can now argue about it on cost and constraints instead of on stability — which is a much better argument to have, and a stricter one.

Pre-Port Checklist

  1. Resolve the dependency tree and classify every package as pure-Python, Pyodide-built, or native-only.
  2. Confirm each Pyodide build exists at the runtime's pinned version, not just somewhere upstream.
  3. Replace native drivers with pure-Python equivalents and test them over the socket binding.
  4. Delete disk and process assumptions before they delete you in production.
  5. Measure bundle size, cold start, and p95 before committing to the port.
  6. Keep a container fallback for the parts that fail — the front-door pattern needs it anyway.

Further Reading

Conclusion

This is not a language migration — the Python syntax mostly survives untouched. It is a dependency and runtime-contract migration, and the list of things that break is short, predictable, and discoverable before you write a single Worker line: native wheels, process assumptions, disk assumptions, and the per-call cost of crossing into JavaScript. Port the request path, leave the compute in a container, and decide with measurements rather than with the announcement.

Share this post

More posts

Comments