# Running Python in the Browser: Pyodide and WebContainer Explained URL: https://madhudadi.in/blog/posts/running-python-in-the-browser-with-pyodide-webcontainer Published: 2026-06-14 Tags: Architecture, Next.js, Production, python Read time: 17 min Difficulty: advanced > WebContainer integration, Monaco editor, xterm terminal, Pyodide Python runner, filesystem sandbox, data file uploads, and the security model that keeps it all safe.# 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: ```tsx async function runPyodide(code: string): Promise { 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: ```typescript // 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: ```tsx const workerRef = useRef(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: ```typescript 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: ```tsx import Editor from "@monaco-editor/react"; function InteractiveCodeCell({ code, language, onChange }: Props) { return (
{language === "python" ? "Python" : language}
onChange?.(val ?? "")} theme="vs-dark" options={{ minimap: { enabled: false }, fontSize: 14, lineNumbers: "off", scrollBeyondLastLine: false, padding: { top: 12, bottom: 12 }, readOnly: false, }} />
); } ``` --- ## Xterm Terminal Integration The output area uses xterm.js for a terminal-like experience: ```tsx import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; function OutputTerminal({ output }: { output: string }) { const terminalRef = useRef(null); const terminal = useRef(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
; } ``` --- ## Filesystem Sandbox Posts can include data files (CSV, Excel) that are mounted into the WebContainer filesystem: ```typescript interface DataFile { name: string; url: string; } async function mountDataFiles(container: WebContainer, files: DataFile[]) { const mountEntries: Record = {}; 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: ```tsx function DataFileBrowser({ files }: { files: DataFile[] }) { return (

Data Files

{files.map((file) => (
{file.name}
))}
); } ``` --- ## 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: ```typescript 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.*