Skip to content

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:

blocking_code.py
import time
# Simulate a long computation
for 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:

JavaScript Main Thread Responsibilities
┌─────────────────────────────────────────────────────┐
│ 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:

test-blocking.html
<!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:

blocking_operations.py
# These operations block the main thread:
# 1. time.sleep() - pauses execution
import time
time.sleep(5) # UI frozen for 5 seconds
# 2. Heavy computations
result = sum(i * i for i in range(10000000)) # UI frozen during calculation
# 3. input() - waits for user input
name = input("Enter name: ") # UI frozen waiting for input
# 4. Synchronous file I/O (if available)
# Blocking until file read completes

The 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.

Architecture Comparison
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:

pyodide-worker.js
let pyodide = null;
// Load Pyodide inside the worker
async 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 thread
self.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

non-blocking.html
<!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:

pyodide-worker-input.js
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:

main-thread-input.js
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 input
worker.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:

Event Loop Visualization
┌─────────────────────────────────────────────────────┐
│ 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 vs Web Worker
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:

message-passing.js
// Main thread
worker.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 result
worker.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:

worker-dom-error.js
// In worker - THIS DOESN'T WORK
self.onmessage = function(e) {
// Error: document is not defined
document.getElementById('output').textContent = 'result';
};

Instead, send the result to the main thread:

worker-dom-correct.js
// In worker - correct approach
self.postMessage({ type: 'result', data: result });
// In main thread
worker.onmessage = function(e) {
document.getElementById('output').textContent = e.data.data;
};

Pitfall 2: Not Handling Worker Errors

Workers can fail silently. Always add error handling:

worker-error-handling.js
const worker = new Worker('pyodide-worker.js');
// Handle worker errors
worker.onerror = function(e) {
console.error('Worker error:', e.message);
// Show user-friendly error
document.getElementById('output').textContent =
'Worker failed: ' + e.message;
};
// Handle messages
worker.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:

memory-leak.js
// BAD: Creating workers without cleanup
function runCode(code) {
const worker = new Worker('pyodide-worker.js');
worker.postMessage({ type: 'run', data: { code } });
// Worker is never terminated!
}
// GOOD: Reuse or terminate workers
let pyodideWorker = null;
function getWorker() {
if (!pyodideWorker) {
pyodideWorker = new Worker('pyodide-worker.js');
pyodideWorker.postMessage({ type: 'init' });
}
return pyodideWorker;
}
// Or terminate when done
function 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:

transferable-objects.js
// SLOW: Copies the entire buffer
const 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:

  1. Move Pyodide into a Web Worker for parallel execution
  2. Use message passing (postMessage) for communication between threads
  3. Handle input() with promise-based stdin interception
  4. 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