How BabyCode Runs Real Python in the Browser (Pyodide + Cloudflare Workers, No Backend)
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:
- No install. She's 7. "First, install Python" loses her in 30 minutes.
- 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.
- Free, forever. No paywalls, no in-app purchases. (I refuse to put ads in front of a 7-year-old.)
- 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.
- 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:
- Infinite loops hang the browser tab. Pyodide doesn't have a built-in timeout. I added a
setTimeout(hardKill, 5000)that callspy.runPython('import sys; sys.exit()')if the kid's code runs too long. (Lossy: the kid'swhile True:becomeswhile True: breakafter 5s, but the tab stays alive.) - Imports of non-Pyodide packages (
import requests) throw a clear error. The error message is shown to the kid verbatim —ModuleNotFoundError: No module named 'requests'is a teachable moment, not a UX failure. - The output buffer is small. If a kid writes
while True: print('spam'), the buffer overflows. I truncate to the last 4KB before displaying.
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:
- Follow the user across devices, without a login.
- Be queryable by parents (so they can see "how far has my kid gotten").
- Cost me $0/month at low traffic.
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
- It's not "real" cross-device sync. If a kid switches to a new device, they have to copy the device ID. In practice, this is fine — most kids use 1-2 devices.
- No parental dashboard. I could build "enter your kid's device ID to see their progress" but it would create a target for abuse. I haven't built it.
- KV is eventually consistent. Writes propagate in <60s globally. For an educational app where a kid is the only writer, this is fine.
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:
- Storage: 1,000 × 5KB = 5MB = $0.0025/month
- Writes: each kid saves progress ~10 times per session × 5 sessions/week = 50 writes/week/kid = 50,000 writes/week = $0.25/month
- Reads: each page load = 1 read × 5 sessions/week = 5,000 reads/week = $0.025/month
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:
- Global CDN (Cloudflare's 300+ edge locations)
- Free TLS
- Auto-deploy from git push
- A single Cloudflare Worker at
functions/[[path]].jsthat handles:/api/stats(visitor counter, KV-backed)/api/progress(the progress persistence from Layer 2)/api/progress/reset/about,/en/about(serves the staticabout.htmlwith i18n flip)- SPA fallback (everything else →
index.html)
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:
- 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.) - 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.
- 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:
| Component | Monthly 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