Running Python in the Browser
Every Python code block on this blog is editable and runnable. Readers can modify the code, execute it, and see the output — all within the browser, without a backend sandbox.
There are two execution modes: Pyodide (lightweight, instant) and WebContainer (full environment, slower startup). Here's how both work.
Pyodide: Instant Python Execution
For simple code blocks (standard library only, no external packages), Pyodide runs Python compiled to WebAssembly directly in the browser:
async function runPyodide(code: string): Promise<string> {
const pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.25.0/full/" });
const output: string[] = [];
pyodide.setStdout({ batched: (text: string) => output.push(text) });
pyodide.setStderr({ batched: (text: string) => output.push(text) });
await pyodide.runPythonAsync(code);
return output.join("\n");
}The Pyodide worker lives in a separate Web Worker to avoid blocking the main thread:
// src/workers/pythonRunner.worker.ts
self.onmessage = async (e: MessageEvent) => {
const { code, type } = e.data;
if (type === "run") {
try {
const result = await runPyodide(code);
self.postMessage({ type: "result", output: result });
} catch (err) {
self.postMessage({ type: "error", message: (err as Error).message });
}
}
};The frontend communicates with the worker:
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(
new URL("@/workers/pythonRunner.worker", import.meta.url)
);
return () => workerRef.current?.terminate();
}, []);
function runCode(code: string) {
setRunning(true);
workerRef.current?.postMessage({ code, type: "run" });
workerRef.current!.onmessage = (e) => {
if (e.data.type === "result") setOutput(e.data.output);
if (e.data.type === "error") setError(e.data.message);
setRunning(false);
};
}WebContainer: Full Environment
For posts that need external packages (pandas, requests, numpy) or a real filesystem, WebContainer spins up a full Node.js environment in the browser, which then runs Python:
import { WebContainer } from "@webcontainer/api";
let containerInstance: WebContainer | null = null;
async function getContainer() {
if (!containerInstance) {
containerInstance = await WebContainer.boot();
}
return containerInstance;
}
async function runInContainer(code: string, files: DataFile[]) {
const container = await getContainer();
await container.mount({
"script.py": { file: { contents: code } },
...Object.fromEntries(
files.map((f) => [f.name, { file: { contents: f.url } }])
),
});
const process = await container.spawn("python3", ["script.py"]);
const output: string[] = [];
process.output.pipeTo(
new WritableStream({
write(data) { output.push(data); },
})
);
await process.exit;
return output.join("");
}WebContainer starts in about 5-10 seconds on first load (cached after that). The trade-off: startup time for capability. Simple code blocks use Pyodide (instant); complex code blocks use WebContainer (5s startup, full package support).
Monaco Editor Integration
Code blocks render as Monaco editor instances with Python syntax highlighting:
import Editor from "@monaco-editor/react";
function InteractiveCodeCell({ code, language, onChange }: Props) {
return (
<div className="rounded-xl overflow-hidden border border-white/[0.08] my-6">
<div className="flex items-center justify-between px-4 py-2 bg-white/[0.02] border-b border-white/[0.08]">
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">
{language === "python" ? "Python" : language}
</span>
<RunButton />
</div>
<Editor
height="auto"
defaultLanguage={language === "python" ? "python" : language}
value={code}
onChange={(val) => onChange?.(val ?? "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "off",
scrollBeyondLastLine: false,
padding: { top: 12, bottom: 12 },
readOnly: false,
}}
/>
<OutputTerminal output={output} />
</div>
);
}Xterm Terminal Integration
The output area uses xterm.js for a terminal-like experience:
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
function OutputTerminal({ output }: { output: string }) {
const terminalRef = useRef<HTMLDivElement>(null);
const terminal = useRef<Terminal | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
terminal.current = new Terminal({
theme: { background: "#0d0d0f", foreground: "#e4e4e4", cursor: "#e4e4e4" },
fontSize: 13,
fontFamily: "'JetBrains Mono', monospace",
rows: 10,
cursorBlink: false,
disableStdin: true,
});
const fitAddon = new FitAddon();
terminal.current.loadAddon(fitAddon);
terminal.current.open(terminalRef.current);
fitAddon.fit();
return () => terminal.current?.dispose();
}, []);
useEffect(() => {
if (terminal.current && output) {
terminal.current.clear();
terminal.current.write(output);
}
}, [output]);
return <div ref={terminalRef} className="h-48" />;
}Filesystem Sandbox
Posts can include data files (CSV, Excel) that are mounted into the WebContainer filesystem:
interface DataFile {
name: string;
url: string;
}
async function mountDataFiles(container: WebContainer, files: DataFile[]) {
const mountEntries: Record<string, { file: { contents: string } }> = {};
for (const file of files) {
const response = await fetch(file.url);
const contents = await response.text();
mountEntries[file.name] = { file: { contents } };
}
await container.mount(mountEntries);
}The frontend shows a file browser for uploaded data files:
function DataFileBrowser({ files }: { files: DataFile[] }) {
return (
<div className="card p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-white/40 mb-3">
Data Files
</p>
<div className="space-y-2">
{files.map((file) => (
<div key={file.name}
className="flex items-center gap-2 text-sm text-white/70"
>
<FileIcon extension={file.name.split(".").pop()!} />
<span>{file.name}</span>
</div>
))}
</div>
</div>
);
}Security Model
Three layers of security prevent malicious code from escaping the sandbox:
Layer 1 — Pyodide's WebAssembly sandbox. Pyodide runs in a Web Worker with no access to the DOM, localStorage, or network. Code can only compute and print. No os.system(), no subprocess, no file writes outside the virtual filesystem.
Layer 2 — WebContainer's restricted environment. WebContainer provides a restricted POSIX-like environment. Network requests go through an HTTP proxy that blocks private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x) and limits outbound connections to allowlisted domains.
Layer 3 — Frontend isolation. Each code cell is rendered in an isolated React component. If Pyodide or WebContainer crashes, only that cell is affected. The rest of the page continues working.
Detection: Pyodide vs. WebContainer
The post editor has a toggle for "Enable Filesystem." When enabled, the post uses WebContainer. Otherwise, Pyodide is used:
const enableFilesystem = postData?.enable_filesystem ?? false;
async function runCode(code: string) {
if (enableFilesystem) {
return runInContainer(code, dataFiles);
} else {
return runPyodide(code);
}
}The decision is made at post-authoring time based on whether the post needs data files or external packages. Most posts use Pyodide. Posts with data analysis (pandas, numpy) or file I/O use WebContainer.
What's Next
The next post covers the Interview Simulator — AI-powered mock interviews, question generation from reading history, difficulty scaling, and feedback scoring.
Built with Pyodide, WebContainer, Monaco Editor, xterm.js, and zero server-side code execution.
