Architecture Pyodide Cloudflare Python

How BabyCode Runs Real Python in the Browser (Pyodide + Cloudflare Workers, No Backend)

BabyCode Team · 2026-06-06 · 12 min read

TL;DR: BabyCode is a free, browser-based coding site for kids 6-12. It runs real CPython (not a JS imitation, not block-based) directly in the browser via Pyodide (CPython compiled to WebAssembly), with zero install. Progress is persisted in Cloudflare Workers KV, not localStorage. There is no per-user backend. Total Cloudflare bill: $0/month at current traffic. This post is a technical walkthrough of how the architecture works and the 3 hard problems I had to solve.

Live: https://babycode.online/

The constraints I started with

I built BabyCode for my 7-year-old daughter. The constraints, in order of importance:

  1. No install. She's 7. "First, install Python" loses her in 30 minutes.
  2. Real Python, not a toy language. She'll outgrow turtle in 3 months. I want her to learn the actual language she'll use for the next decade.
  3. Free, forever. No paywalls, no in-app purchases. (I refuse to put ads in front of a 7-year-old.)
  4. No signup. Kids don't have emails. Parents won't create accounts for "yet another edtech site." Progress must work across devices without a login wall.
  5. Cheap to run. I don't want a $200/month Postgres bill for a site 99% of users will use once and leave.

These 5 constraints ruled out almost every "Python in the browser" tutorial I found online. Most of them either required a server, used a JS imitation of Python, or required installing something. So I built the architecture bottom-up. Here's what I landed on.

Layer 1: The browser runs real Python via Pyodide

Pyodide is CPython 3.11 compiled to WebAssembly. You load it as a JS module, and pyodide.runPython("print('hello')") executes real Python — including imports, exceptions, the sys module, and a growing subset of the standard library.

For BabyCode, the integration looks like this:

// src/lib/pyodide.ts (abridged)
import { loadPyodide, type PyodideInterface } from 'pyodide'

let _pyodide: PyodideInterface | null = null

export async function getPyodide(): Promise<PyodideInterface> {
  if (_pyodide) return _pyodide
  // Load from CDN. ~10MB compressed, but cached after first load.
  _pyodide = await loadPyodide({
    indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.7/full/',
  })
  _pyodide.setStdout({ batched: (s) => appendToOutput(s + '\n') })
  _pyodide.setStderr({ batched: (s) => appendToOutput(s + '\n') })
  return _pyodide
}

export async function runKidCode(code: string): Promise<RunResult> {
  const py = await getPyodide()
  const output: string[] = []
  py.setStdout({ batched: (s) => output.push(s) })
  py.setStderr({ batched: (s) => output.push(s) })
  try {
    await py.runPythonAsync(code)
    return { ok: true, output: output.join('') }
  } catch (err) {
    return { ok: false, output: output.join(''), error: String(err) }
  }
}

This is real Python. If a kid types import math; print(math.pi), it works. If they import random, json, datetime, collections, itertools — all work. If they import numpy or pandas, those work too (Pyodide ships a surprising number of pure-Python and C-extension packages).

The Pyodide WASM bundle is ~10MB compressed. Loaded from CDN, it caches in the browser after the first visit. Subsequent page loads don't re-download it. On a 4G connection, cold start is ~5-10 seconds; warm start is <100ms.

What I had to solve: making the kid's code run "synchronously enough"

Pyodide's runPythonAsync returns a Promise, but kid code is usually synchronous print() statements. The async wrapper mostly just works, but there are edge cases:

Layer 2: Progress persists in Cloudflare Workers KV, not localStorage

The "no signup, works across devices" constraint is the unusual one. Most "no signup" sites use localStorage, which means: switching from iPad to laptop loses all progress. Clearing browser data loses all progress. Parents can't see what their kid is doing (because the data is on the kid's device).

I wanted progress to:

The trick: a device-scoped anonymous ID stored in localStorage, used as a key in Cloudflare Workers KV. No user accounts, no auth, but the data is server-side.

// Anonymous device ID — generated once, stored forever in localStorage.
function getDeviceId(): string {
  let id = localStorage.getItem('babycode:device-id')
  if (!id) {
    id = crypto.randomUUID()
    localStorage.setItem('babycode:device-id', id)
  }
  return id
}

// Save progress to Cloudflare Workers KV.
async function saveProgress(progress: ProgressState) {
  await fetch(`${API_BASE}/api/progress`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ deviceId: getDeviceId(), progress }),
  })
}

The KV key is just the device ID. No PII, no auth, no SQL.

Tradeoffs I accepted

What I had to solve: cost and rate limits

Cloudflare Workers KV is $0.50/GB-month stored + $1.00/million reads + $5.00/million writes. At 1,000 active kids with ~5KB of progress each:

Total: ~$0.30/month at 1,000 active kids. The actual bill: $0.00/month so far. I'm well within Cloudflare's free tier.

Layer 3: Hosting the static frontend on Cloudflare Pages

The frontend is a Vite + React SPA. It outputs static HTML/JS/CSS. I serve it from Cloudflare Pages, which gives me:

The Worker is ~200 lines of JS. The whole project fits in one repo.

What I had to solve: the SPA fallback edge case

Cloudflare Pages' default behavior is to serve index.html for any path that doesn't match a static file. But Wrangler 4.96+ flags /* /index.html 200 in _redirects as a potential infinite loop. The fix: put the SPA fallback in functions/[[path]].js instead, and use _routes.json to control which paths reach the function:

// public/_routes.json
{
  "version": 1,
  "include": ["/*"],
  "exclude": [
    "/about.html", "/favicon.svg", "/og-image.png",
    "/blog/*", "/en/blog/*",
    "/assets/*",
    "/typing", "/python", "/cpp", "/en/typing", "/en/python", "/en/cpp"
  ]
}

Anything in exclude is served directly from disk. Everything else hits the Worker, which either handles /api/* or returns index.html for SPA routes.

This works because Cloudflare Pages prerenders every reachable route at build time, so the SPA can deep-link to /python/p2-3 and the Worker returns a fully-rendered HTML page. Search engines see the rendered HTML, not a <div id="root"></div> shell.

The 3 hard problems I had to solve

Problem 1: Pyodide cold-start is 5-10 seconds on first visit

A 7-year-old will not wait 8 seconds for the first lesson to load. They'll click away.

Solution: aggressive code-splitting + Pyodide lazy-load.

// Only load Pyodide when the kid actually starts a Python lesson.
const pyodidePromise = import('pyodide').then(({ loadPyodide }) => loadPyodide(...))

The home page, typing lessons, and level-select UI all load without Pyodide. Pyodide is only fetched when the kid clicks "Start" on a Python lesson. By that point, the kid is committed to learning, and a 5-second loading spinner is acceptable.

Problem 2: The "no signup, no auth" thing is harder than it sounds

The naive approach — "use localStorage" — fails the cross-device test. The other naive approach — "require email signup" — fails the kid-friendliness test.

Solution: device-scoped anonymous IDs. See Layer 2 above. The device ID is the only thing tying the user to their progress. If they clear localStorage, they get a new device ID, but their old progress is still in KV — they can recover it by entering the old device ID in a "restore progress" UI.

This is anonymous, zero-friction, cross-device, and reversible. It's not as good as a real account system, but it's good enough for a kids' learning site.

Problem 3: The error messages have to be kid-friendly

CPython's SyntaxError: invalid syntax is not helpful for a 7-year-old. NameError: name 'pritn' is not defined. Did you mean: print? is better, but still abstract.

Solution: a small post-processor that catches common errors and rewrites them.

const ERROR_TRANSLATIONS: Record<string, string> = {
  "NameError: name 'pritn' is not defined":
    "💡 看起来 'pritn' 这个名字还没定义。是不是想打 'print'?检查一下拼写。",
  "IndentationError: expected an indented block":
    "💡 Python 用缩进表示代码块。if 后面这行要空 4 个空格再写哦。",
  "SyntaxError: invalid syntax":
    "💡 这行代码有点问题。最常见:少了一个冒号 (:) 或括号不匹配。",
}

I keep this list short and add to it as I see new error patterns in user testing. About 30 entries cover 90% of kid errors. The remaining 10% fall through to the raw CPython error — better than nothing.

What I'd do differently

If I were starting over:

  1. Skip the typing track. I'd still build it, but I'd let the kid start with Python and the typing track in parallel, not sequentially. Some kids want to start with print() immediately; making them do 13 typing lessons first is friction. (My current data shows 70% of kids skip the typing track if they can. I might be wrong about the order.)
  2. Add a real account system later. Not now — the friction of "create account" loses 80% of 7-year-olds. But by age 9-10, kids can handle a username/password. I'd add it then.
  3. Use a service worker for offline Pyodide. Right now, if the kid's internet drops mid-lesson, Pyodide fails. Caching the WASM in a service worker would fix this. I haven't done it because Pyodide's 10MB cache is heavy for first-time visitors.

The cost so far

For transparency, here's what BabyCode costs me to run:

ComponentMonthly cost
Cloudflare Pages (hosting)$0 (free tier)
Cloudflare Workers (API)$0 (free tier)
Cloudflare Workers KV (progress + stats)$0 (free tier)
Pyodide CDN (jsdelivr)$0 (free)
Domain (babycode.online)$1/month
Total~$1/month

This is sustainable for a side project. I could scale to 100,000 active users before hitting paid tiers, and even then it'd be <$50/month.


Try it

If you have a kid 6-12, or you just want to poke at Pyodide without setting up a project: https://babycode.online/

The first lesson is print("你好,世界!") in Chinese or print("Hello, world!") in English. The kid's first line of code, in their own language, runs in under 10 seconds.

If you're a dev curious about the architecture: the code is open-source (planning to publish in Q3 2026 — drop your email on the about page if you want a notification).

AMA in the comments or at hi@babycode.online.

→ Try BabyCode