Why Does Pyodide Block the UI and How to Run It in a Web Worker?
Problem
I was building a browser-based Python playground. Everything worked fine for simple calculations, but when I ran a long computation or used time.sleep(), the entire browser tab froze. Buttons became unresponsive, animations stopped, and users couldn’t even scroll the page.
The code was simple enough:
import time
# Simulate a long computationfor i in range(10): time.sleep(1) # This freezes the browser for 10 seconds! print(f"Progress: {i+1}/10")When I clicked “Run”, the UI completely locked up. I couldn’t click anything, couldn’t scroll, couldn’t even close the tab normally. This is a terrible user experience.
Environment
- Python 3.11 (via Pyodide)
- Pyodide 0.24.1
- Modern browser with WebAssembly and Web Worker support
- Node.js 20 (for local development server)
What Happened?
The freeze happens because of JavaScript’s single-threaded nature. Let me explain.
The Single-Threaded Problem
JavaScript runs on a single main thread. This thread handles everything:
┌─────────────────────────────────────────────────────┐│ MAIN THREAD │├─────────────────────────────────────────────────────┤│ • UI rendering and painting ││ • DOM manipulation ││ • User event handling (clicks, scrolls, input) ││ • Network request callbacks ││ • AND your Pyodide Python code! │└─────────────────────────────────────────────────────┘When Pyodide runs Python code, it occupies this single thread. Nothing else can happen until the Python code finishes. Here’s what I observed:
<!DOCTYPE html><html><head> <title>Pyodide Blocking Test</title></head><body> <button id="run">Run Blocking Code</button> <button id="test">Test UI (click me during execution)</button> <div id="output"></div> <div id="clicks">Clicks: 0</div>
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script> <script> let pyodide = null; let clickCount = 0;
async function initPyodide() { pyodide = await loadPyodide(); document.getElementById('output').textContent = 'Ready'; }
async function runBlockingCode() { if (!pyodide) return;
document.getElementById('output').textContent = 'Running...';
// This blocks the main thread! pyodide.runPython(` import time for i in range(5): time.sleep(1) print(f"Step {i+1}/5") `);
document.getElementById('output').textContent = 'Done!'; }
document.getElementById('run').addEventListener('click', runBlockingCode); document.getElementById('test').addEventListener('click', () => { clickCount++; document.getElementById('clicks').textContent = `Clicks: ${clickCount}`; });
initPyodide(); </script></body></html>When I clicked “Run Blocking Code”, the “Test UI” button became completely unresponsive. The click counter wouldn’t update. The browser was stuck.
Why Certain Operations Block More
Some Python operations are inherently blocking:
# These operations block the main thread:
# 1. time.sleep() - pauses executionimport timetime.sleep(5) # UI frozen for 5 seconds
# 2. Heavy computationsresult = sum(i * i for i in range(10000000)) # UI frozen during calculation
# 3. input() - waits for user inputname = input("Enter name: ") # UI frozen waiting for input
# 4. Synchronous file I/O (if available)# Blocking until file read completesThe worst offender is input(). In a collaborative IDE like PyTogether (described as “Google Docs for Python”), input() needs user interaction. But if it blocks the main thread, the user can’t even type in the input field!
How to Solve It: Web Workers
The solution is to move Pyodide into a Web Worker. Web Workers run in a separate thread from the main thread, so blocking operations don’t freeze the UI.
BEFORE (Blocking):┌─────────────┐│ Main Thread │ ←── Python code runs here├─────────────┤ Everything freezes during execution│ UI Events ││ Rendering │└─────────────┘
AFTER (Non-Blocking):┌─────────────┐ ┌──────────────┐│ Main Thread │ │ Web Worker │├─────────────┤ ├──────────────┤│ UI Events │ │ Pyodide │ ←── Python runs here│ Rendering │ │ Python code ││ Responsive! │ │ (blocking OK)│└─────────────┘ └──────────────┘ ↑ ↑ └──── messages ──────┘Step 1: Create the Web Worker
First, I created a separate worker file:
let pyodide = null;
// Load Pyodide inside the workerasync function initPyodide() { importScripts('https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js'); pyodide = await loadPyodide();
// Load common packages await pyodide.loadPackage(['numpy', 'matplotlib']);
// Notify main thread that we're ready 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 { // Set up stdout capture pyodide.runPython(` import sys from io import StringIO sys.stdout = StringIO() sys.stderr = StringIO() `);
// Run the user's code pyodide.runPython(data.code);
// Capture output const stdout = pyodide.runPython('sys.stdout.getvalue()'); const stderr = pyodide.runPython('sys.stderr.getvalue()');
// Send result back to main thread self.postMessage({ type: 'result', data: { stdout, stderr } }); } catch (err) { self.postMessage({ type: 'error', data: { message: err.message } }); } }};Step 2: Update the Main Thread to Use the Worker
<!DOCTYPE html><html><head> <title>Pyodide with Web Worker</title> <style> body { font-family: sans-serif; padding: 20px; } #status { padding: 10px; margin-bottom: 10px; } .loading { background: #fff3cd; } .ready { background: #d4edda; } .running { background: #cce5ff; } #output { background: #f5f5f5; padding: 15px; min-height: 100px; } </style></head><body> <h2>Non-Blocking Python Playground</h2> <div id="status" class="loading">Loading Pyodide in worker...</div>
<textarea id="code" rows="8" cols="60">import time
print("Starting computation...")for i in range(5): time.sleep(1) print(f"Progress: {i+1}/5")print("Done!") </textarea> <br><br>
<button id="run" disabled>Run Code</button> <button id="test">Test UI (click during execution)</button>
<h3>Output:</h3> <pre id="output"></pre>
<p id="clicks">Click count: 0</p>
<script> // Create the worker const worker = new Worker('pyodide-worker.js'); const runButton = document.getElementById('run'); const outputDiv = document.getElementById('output'); const statusDiv = document.getElementById('status'); let clickCount = 0;
// 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 || '(no output)'; statusDiv.className = 'ready'; statusDiv.textContent = 'Execution complete'; runButton.disabled = false; } else if (type === 'error') { outputDiv.textContent = 'Error: ' + data.message; statusDiv.className = 'ready'; statusDiv.textContent = 'Error occurred'; runButton.disabled = false; } };
// Initialize the worker worker.postMessage({ type: 'init' });
// Run code button runButton.addEventListener('click', () => { const code = document.getElementById('code').value; outputDiv.textContent = 'Running...'; statusDiv.className = 'running'; statusDiv.textContent = 'Executing (UI still responsive!)'; runButton.disabled = true; worker.postMessage({ type: 'run', data: { code } }); });
// Test UI responsiveness document.getElementById('test').addEventListener('click', () => { clickCount++; document.getElementById('clicks').textContent = `Click count: ${clickCount}`; }); </script></body></html>Now when I run the code with time.sleep(), the UI stays responsive! I can click the “Test UI” button during execution and see the click counter update in real-time.
Step 3: Handling Input() in Web Workers
The input() function is tricky because it’s synchronous by nature. The worker needs to pause and wait for the main thread to provide input. I found a library called pyodide-worker-runner that handles this elegantly.
Here’s how I implemented custom input handling:
let pyodide = null;let inputResolve = null;
async function initPyodide() { importScripts('https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js'); pyodide = await loadPyodide();
// Set up custom stdin handler pyodide.setStdin({ stdin: () => { // This is called when Python calls input() // We need to return a promise that resolves when the user provides input return new Promise((resolve) => { inputResolve = resolve; // Tell main thread we need input 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') { await runCode(data.code); } else if (type === 'input_response') { // Main thread provided the input if (inputResolve) { inputResolve(data.value); inputResolve = null; } }};
async function runCode(code) { try { pyodide.runPython(` import sys from io import StringIO sys.stdout = StringIO() `);
await pyodide.runPythonAsync(code);
const output = pyodide.runPython('sys.stdout.getvalue()'); self.postMessage({ type: 'result', data: { output } }); } catch (err) { self.postMessage({ type: 'error', data: { message: err.message } }); }}And the main thread handles the input prompt:
const worker = new Worker('pyodide-worker-input.js');
worker.onmessage = function(e) { const { type, data } = e.data;
if (type === 'input_required') { // Show a prompt to the user const value = prompt('Python requests input:'); worker.postMessage({ type: 'input_response', data: { value } }); } else if (type === 'result') { console.log('Output:', data.output); }};
// Run code with inputworker.postMessage({ type: 'run', data: { code: `name = input("What is your name? ")print(f"Hello, {name}!") ` }});Now input() works without freezing the UI!
The Reason: Why This Works
JavaScript’s Event Loop
JavaScript uses an event loop model. The main thread processes one task at a time:
┌─────────────────────────────────────────────────────┐│ CALL STACK ││ ┌─────────────────────────────────────────────┐ ││ │ Currently executing (BLOCKS everything else)│ ││ └─────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────────────┐│ EVENT QUEUE ││ [click handler] [scroll handler] [timer] [fetch] ││ ← Waiting for call stack to empty │└─────────────────────────────────────────────────────┘When Pyodide runs Python on the main thread, it sits in the call stack. Nothing in the event queue can execute until Python finishes. Clicks, scrolls, animations—all queued and waiting.
Web Workers: True Parallelism
Web Workers are part of the Web Workers API, providing true parallel execution:
MAIN THREAD WEB WORKER───────────── ─────────────Event Loop 1 Own Thread └── UI render └── Python execution └── Click handler (blocking OK here) └── Scroll handler └── Animation frame └── Network callback
Both run in parallel!Message Passing Protocol
Workers communicate via postMessage(), which is asynchronous:
// Main threadworker.postMessage({ type: 'run', data: { code: 'print(1+1)' } });// This returns immediately! Main thread is free.
// Worker thread (separate thread)self.onmessage = function(e) { // Process in background const result = runPython(e.data.code); // Send result back self.postMessage({ type: 'result', data: result });};
// Main thread receives resultworker.onmessage = function(e) { console.log('Got result:', e.data.data);};This message-passing pattern ensures the main thread never blocks.
Common Pitfalls
Pitfall 1: Trying to Access DOM from Worker
Workers cannot access the DOM. This will fail:
// In worker - THIS DOESN'T WORKself.onmessage = function(e) { // Error: document is not defined document.getElementById('output').textContent = 'result';};Instead, send the result to the main thread:
// In worker - correct approachself.postMessage({ type: 'result', data: result });
// In main threadworker.onmessage = function(e) { document.getElementById('output').textContent = e.data.data;};Pitfall 2: Not Handling Worker Errors
Workers can fail silently. Always add error handling:
const worker = new Worker('pyodide-worker.js');
// Handle worker errorsworker.onerror = function(e) { console.error('Worker error:', e.message); // Show user-friendly error document.getElementById('output').textContent = 'Worker failed: ' + e.message;};
// Handle messagesworker.onmessage = function(e) { if (e.data.type === 'error') { // Python execution error console.error('Python error:', e.data.data.message); }};Pitfall 3: Memory Leaks from Unmanaged Workers
Workers persist until terminated. Create too many, and memory leaks:
// BAD: Creating workers without cleanupfunction runCode(code) { const worker = new Worker('pyodide-worker.js'); worker.postMessage({ type: 'run', data: { code } }); // Worker is never terminated!}
// GOOD: Reuse or terminate workerslet pyodideWorker = null;
function getWorker() { if (!pyodideWorker) { pyodideWorker = new Worker('pyodide-worker.js'); pyodideWorker.postMessage({ type: 'init' }); } return pyodideWorker;}
// Or terminate when donefunction cleanup() { if (pyodideWorker) { pyodideWorker.terminate(); pyodideWorker = null; }}Pitfall 4: Large Data Transfers
Passing large data between main thread and worker is expensive. Use Transferable objects for buffers:
// SLOW: Copies the entire bufferconst largeArray = new Float64Array(1000000);worker.postMessage({ type: 'process', data: largeArray });
// FAST: Transfers ownership (no copy)const largeArray = new Float64Array(1000000);worker.postMessage( { type: 'process', data: largeArray }, [largeArray.buffer] // Transfer ownership);// Note: largeArray is now empty in main thread!Summary
In this post, I showed why Pyodide blocks the UI and how to solve it using Web Workers. The core issue is JavaScript’s single-threaded nature—when Python code runs on the main thread, nothing else can happen until it finishes.
The solution is straightforward:
- Move Pyodide into a Web Worker for parallel execution
- Use message passing (
postMessage) for communication between threads - Handle
input()with promise-based stdin interception - Be aware of common pitfalls like DOM access and memory leaks
This approach is exactly what projects like PyTogether (a “Google Docs for Python”) use to enable real-time collaborative Python coding in the browser. The worker handles the blocking Python code while the main thread stays responsive for user interactions.
If you’re building any browser-based Python execution environment, always use Web Workers. The alternative—a frozen UI—is simply not acceptable for modern web applications.
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