How to Run Python in the Browser with Pyodide and Web Workers
Purpose
I wanted to build a browser-based Python code editor where users could write and execute Python code directly in the browser. The traditional approach requires a backend server to run Python, which adds latency, server costs, and deployment complexity. I needed a way to run Python entirely on the client side for an interactive learning platform.
Environment
- Python 3.11 (via Pyodide)
- Pyodide 0.24.1
- Modern browser with WebAssembly support
- Node.js 20 (for development server)
What Happened?
When I started building this Python playground, I assumed I needed a backend server. The typical architecture looks like this:
[Browser] --HTTP Request--> [Backend Server] --Execute--> [Python Interpreter][Browser] <--HTTP Response-- [Backend Server] <--Result-- [Python Interpreter]This architecture has several problems:
- Latency: Every code execution requires a network round-trip
- Server Costs: You need to scale servers for concurrent users
- Security: Running arbitrary user code on your server is risky
- Complexity: Deploy and maintain a Python execution environment
I wanted something simpler. Then I discovered Pyodide—a Python distribution compiled to WebAssembly that runs entirely in the browser.
How to Solve It?
Basic Approach: Running Pyodide on the Main Thread
First, I tried the simplest approach: loading Pyodide directly on the main thread.
<!DOCTYPE html><html><head> <title>Python in Browser</title></head><body> <textarea id="code" rows="10" cols="50">print("Hello from Python!")import numpy as npprint(np.array([1, 2, 3]).sum()) </textarea> <button id="run">Run Code</button> <pre id="output"></pre>
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script> <script> let pyodide = null;
async function initPyodide() { pyodide = await loadPyodide(); // Load numpy await pyodide.loadPackage(['numpy']); document.getElementById('output').textContent = 'Pyodide loaded!'; }
async function runCode() { if (!pyodide) { document.getElementById('output').textContent = 'Pyodide still loading...'; return; }
const code = document.getElementById('code').value; try { // Redirect stdout pyodide.runPython(` import sys from io import StringIO sys.stdout = StringIO() `);
// Run user code pyodide.runPython(code);
// Get output const output = pyodide.runPython('sys.stdout.getvalue()'); document.getElementById('output').textContent = output; } catch (err) { document.getElementById('output').textContent = 'Error: ' + err.message; } }
document.getElementById('run').addEventListener('click', runCode); initPyodide(); </script></body></html>This works, but I immediately hit a problem. When I ran code with blocking operations like input(), the entire browser tab froze. The UI became completely unresponsive.
# This freezes the browser!name = input("Enter your name: ")print(f"Hello, {name}!")The issue is that Pyodide runs on the main thread, and blocking Python operations block the entire browser UI. No clicks, no scrolling, nothing—until the code finishes.
Production Approach: Web Worker Execution
To solve the blocking problem, I moved Pyodide into a Web Worker. Web Workers run in a separate thread, so blocking operations don’t freeze the main thread.
Here’s the refactored architecture:
[Main Thread] --postMessage--> [Web Worker] | | | (UI responsive) | (Pyodide runs here) | |[Main Thread] <--postMessage-- [Web Worker]First, I created the worker file:
let pyodide = null;
// Initialize Pyodide in the workerasync function initPyodide() { importScripts('https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js'); pyodide = await loadPyodide(); await pyodide.loadPackage(['numpy', 'matplotlib']); self.postMessage({ type: 'ready' });}
// Handle messages from main threadself.onmessage = async function(e) { const { type, data } = e.data;
if (type === 'init') { await initPyodide(); } else if (type === 'run') { try { // Redirect stdout pyodide.runPython(` import sys from io import StringIO sys.stdout = StringIO() sys.stderr = StringIO() `);
// Run user code pyodide.runPython(data.code);
// Get outputs const stdout = pyodide.runPython('sys.stdout.getvalue()'); const stderr = pyodide.runPython('sys.stderr.getvalue()');
self.postMessage({ type: 'result', data: { stdout, stderr } }); } catch (err) { self.postMessage({ type: 'error', data: { message: err.message } }); } }};Then, I updated the main HTML to use the worker:
<!DOCTYPE html><html><head> <title>Python in Browser (Web Worker)</title> <style> .loading { color: gray; } .error { color: red; } .output { background: #f5f5f5; padding: 10px; } </style></head><body> <h2>Python Playground</h2> <div id="status" class="loading">Loading Pyodide...</div>
<textarea id="code" rows="10" cols="60">import numpy as np
# Generate some datadata = np.random.randn(1000)print(f"Mean: {data.mean():.4f}")print(f"Std: {data.std():.4f}")print(f"Sum: {data.sum():.4f}") </textarea> <br> <button id="run" disabled>Run Code</button> <button id="clear">Clear Output</button>
<h3>Output:</h3> <pre id="output" class="output"></pre>
<script> const worker = new Worker('pyodide-worker.js'); const runButton = document.getElementById('run'); const outputDiv = document.getElementById('output'); const statusDiv = document.getElementById('status');
// Handle messages from worker worker.onmessage = function(e) { const { type, data } = e.data;
if (type === 'ready') { statusDiv.textContent = 'Pyodide ready!'; statusDiv.className = 'ready'; runButton.disabled = false; } else if (type === 'result') { outputDiv.textContent = data.stdout || data.stderr; } else if (type === 'error') { outputDiv.textContent = 'Error: ' + data.message; outputDiv.className = 'error'; } };
// Initialize worker worker.postMessage({ type: 'init' });
// Run code button runButton.addEventListener('click', () => { const code = document.getElementById('code').value; outputDiv.textContent = 'Running...'; worker.postMessage({ type: 'run', data: { code } }); });
// Clear button document.getElementById('clear').addEventListener('click', () => { outputDiv.textContent = ''; }); </script></body></html>Now when I run the code, the UI stays responsive. Users can scroll, click buttons, and interact with the page even while Python code is executing.
Handling Input() in Web Workers
For collaborative IDEs that need to handle input(), you need bidirectional communication. Here’s how I implemented it:
let pyodide = null;let inputResolver = null;
async function initPyodide() { importScripts('https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js'); pyodide = await loadPyodide();
// Set up custom input handler pyodide.setStdin({ stdin: () => { // Request input from main thread (synchronously via SharedArrayBuffer) // For simplicity, we use a promise-based approach return new Promise((resolve) => { inputResolver = resolve; self.postMessage({ type: 'input_required' }); }); } });
self.postMessage({ type: 'ready' });}
self.onmessage = async function(e) { const { type, data } = e.data;
if (type === 'init') { await initPyodide(); } else if (type === 'run') { // ... run code as before } else if (type === 'input_response') { // Main thread provided input if (inputResolver) { inputResolver(data.value); inputResolver = null; } }};And the main thread handler:
worker.onmessage = function(e) { const { type, data } = e.data;
if (type === 'input_required') { // Show input dialog to user const value = prompt('Enter input:'); worker.postMessage({ type: 'input_response', data: { value } }); } // ... handle other message types};The Reason
Why does Pyodide work? It leverages WebAssembly.
WebAssembly (Wasm) is a binary instruction format that runs in modern browsers at near-native speed. Pyodide compiles the Python interpreter (CPython) and popular scientific libraries (NumPy, Pandas, Matplotlib) to WebAssembly.
Python Code --> Pyodide (Wasm) --> Browser Wasm Runtime --> Native-like Speed
Compiled to Wasm:- CPython interpreter- NumPy (C/Fortran)- Pandas (Cython)- Matplotlib (C/Cython)The key benefits:
- Zero Backend Costs: No server needed for Python execution. The browser IS the runtime.
- Instant Latency: Code runs locally with no network round-trips.
- Security by Design: CORS and browser sandbox prevent unauthorized network access. Malicious code cannot escape the browser sandbox.
- Full Scientific Stack: NumPy, Pandas, Matplotlib work out of the box.
However, there are limitations:
- Package Support: Only pure Python packages and packages compiled to Wasm work. Not all PyPI packages are available.
- Initial Load Time: Pyodide is ~10MB, so the first load takes a few seconds.
- Memory: Everything runs in browser memory, so large datasets can hit limits.
- Threading: Python’s GIL still applies, though Web Workers provide parallelism.
When to Use Pyodide
Pyodide is ideal for:
- Interactive tutorials and documentation: Users can run code examples directly
- Browser-based IDEs: Like Jupyter Lite or PyTogether
- Scientific computing demos: Show NumPy/Matplotlib without backend
- Sandboxed code execution: Safe execution of untrusted code
It’s NOT ideal for:
- Production backend replacement: Server-side Python is still faster
- Heavy computation: Wasm is fast but slower than native
- Large datasets: Browser memory limits apply
- Proprietary packages: Only open-source packages with Wasm builds work
Summary
In this post, I showed how to run Python directly in the browser using Pyodide and WebAssembly. I started with the basic approach of loading Pyodide on the main thread, which caused UI blocking. Then I moved Pyodide into a Web Worker to keep the UI responsive while Python code executes.
The key takeaways are:
- Pyodide compiles Python to WebAssembly, enabling browser-based execution without a backend
- Always use Web Workers for Pyodide to prevent UI blocking
- The browser sandbox provides natural security boundaries via CORS
- Scientific libraries like NumPy and Matplotlib work out of the box
This approach eliminates backend server costs and latency while providing a secure, sandboxed Python environment directly in the browser.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments