Skip to content

Benchmark

The same Python source on both runtimes — log regex extract, markdown rendering with markdown_it_py + jinja2, and HTML scraping with BeautifulSoup4. Three pymode workers (one per workload, each AOT-built) under *.pymode.teamchong.net, one Pyodide worker handling all three endpoints at pyodide-bench.chong1222.workers.dev.

Same Python source on both runtimes. p50/p99 come from N back-to-back requests sent by your browser when this page loaded (first request discarded as a warmup, so the numbers reflect steady-state warm behaviour).

Cold is the "Worker Startup Time" Cloudflare reports immediately after wrangler deploy — it's the cost of bringing an isolate up before any snapshot system kicks in. You pay it on the very first request to a fresh deploy, or the next request after CF evicts an idle isolate (typically a few minutes of no traffic). You do not pay it on every request — once an isolate is warm in a PoP, subsequent requests in that PoP skip the startup entirely. In production both runtimes also use memory snapshots that reduce this number further than the deploy-time signal shows.

Auto-running benchmark…

Regex log extract

POST /api/logs

Stdlib re — both runtimes can run it. Real CloudWatch log-scrubbing pattern.

Coldp50p99OK
pymode 43 ms
Pyodide (CF) 1827 ms

Markdown → HTML (markdown_it + jinja2)

POST /api/markdown

CommonMark renderer + Python template engine. Both runtimes can run the same packages from pip via uv.

Coldp50p99OK
pymode 61 ms
Pyodide (CF) 1827 ms

HTML scrape with BeautifulSoup

POST /api/scrape

Real-world page → structured title/headings/links via BeautifulSoup4. Both runtimes support bs4 via pip.

Coldp50p99OK
pymode 55 ms
Pyodide (CF) 1827 ms

KV fan-out: 20 reads per request

POST /api/kv-fanout

Read 20 keys from CF KV in a loop, aggregate to JSON. Exercises the Python ↔ JS boundary 20× per request — host-import overhead shows up here.

Coldp50p99OK
pymode 60 ms
Pyodide (CF) 545 ms

numpy: array mean + sum

POST /api/numpy

np.array([1..5]).mean() + sum(). Real numpy import + native array ops. pymode loads numpy via the python-numpy.wasm variant (linalg stubbed); Pyodide loads its pre-built numpy wheel.

Coldp50p99OK
pymode 34 ms
Pyodide (CF) 1559 ms

Each pymode workload runs on its own deployed worker under *.pymode.teamchong.net with tailored preimports — that way the Worker Startup Time CF reports at deploy varies per workload. The Pyodide comparison runs at pyodide-bench.chong1222.workers.dev. First request of each measurement is a discarded warmup so the reported p50/p99 reflect steady-state behaviour. Latencies include network round-trip from your browser.

Cold is the Worker Startup Time Cloudflare reports right after wrangler deploy. You pay it on the first request to a fresh deploy, and on the next request after an idle isolate is evicted from a PoP. You do not pay it on every request. In production, Cloudflare’s Python Workers also use memory snapshots, so an actual cold-isolate spin-up in production is faster than this deploy-time signal — especially for Pyodide. The pymode number is closer to the real cold-isolate cost because pymode’s snapshot is baked into the wasm at deploy via wizer, so every isolate boot reads from the same pre-initialised state.

p50 / p99 are warm-request latencies measured live by your browser. First request of each measurement is discarded as a warmup. Numbers include network RTT from your machine to the closest Cloudflare PoP.

Honest differences between the two runtimes

Section titled “Honest differences between the two runtimes”
pymodePyodide on CF
PythonUpstream CPython 3.13 from sourcePyodide’s port (currently lagging CPython by ~1 minor version)
Deploy flowpymode deploy — AOT-compiles a wasm tailored to the app’s imports (~10 min)pywrangler deploy — Pyodide loads packages at deploy time (~seconds)
Pure-Python pip packagesAny (uv pip install at deploy)Any compatible with Pyodide
Compiled C extensionsCompile via zig cc to wasm32-wasi (manual)Curated set Cloudflare ships (numpy, pandas, Pillow, lxml, etc.)
Host imports (KV, R2, D1, fetch)AOT-specialised: hot-path call sites patched into direct JS stubsJsProxy/PyProxy translation per call
DurableObject classesyesyes (via workers-py SDK)
Per-deploy startuptens of ms~1-2 seconds
Vendored deploy artifactone wasm per workload (~10 MB raw)~1.5 MB modules + Pyodide runtime loaded by CF
  • Upstream CPython. No Pyodide-specific patches around signal handling, threading, random, or filesystem semantics. If you care about exact CPython behaviour, pymode is git-CPython compiled via zig cc. If you want 3.13 features today, you get them; Pyodide’s CPython port lags the upstream release cadence.
  • AOT-specialised host imports. pymode’s deploy CLI generates a wasm tailored to the app’s imports and patches I/O hot paths (KV, R2, fetch, etc.) into direct JS stubs. The trade-off is a much longer deploy in exchange for skipping the Python ↔ JS FFI translation that Pyodide pays per host call. The page doesn’t currently include a workload that exercises this; it shows up in I/O-bound handlers that do many small KV / R2 / fetch operations per request.
  • Compile-anything C extensions. Pyodide ships a curated set of compiled extensions. pymode lets you build any C extension that cross-compiles to wasm32-wasi via zig cc. The long tail of niche scientific libs or internal C modules is where this matters.
  • Deploy iteration. pymode’s AOT build is minutes; Pyodide’s is seconds.
  • Numpy / pandas / Pillow / lxml today. Pyodide ships them; pymode has the python-numpy.wasm variant but hasn’t wired numpy into the AOT deploy path yet, so a non-stdlib numpy app deploys cleanly on Pyodide and doesn’t on pymode.
  • The official integration. Cloudflare maintains workers-py; pymode is a community project against the same platform.

This page exists because pymode is a personal experiment and the official Python Workers are the obvious baseline to compare against. The numbers are what they are.