Standard browser architecture restricts DOM access to the Main Thread. When using Pyodide, running heavy Python logic on the main thread freezes the UI. While Web Workers solve this, they usually require complex message-passing logic in JavaScript, forcing Python developers to write "glue code" in another language.
How can we write UI logic in Python that performs heavy background tasks without freezing the browser, while keeping the code clean, linear, and free of JavaScript boilerplate?
- Main Thread Responsiveness: The browser must remain interactive (scrolling, clicking).
- DOM Access: Only the main thread Pyodide instance can access
js.document. - Async Complexity: Web Workers are inherently asynchronous and event-driven.
- Developer Experience: Python developers prefer
async/awaitand linear flow over JS callbacks.
Use a Hybrid Python Pattern:
sequenceDiagram
participant User
participant Main_Python as Main Thread (Python)
participant Worker_Python as Worker Thread (Python)
participant DOM as Browser DOM
User->>Main_Python: Click Button
Main_Python->>DOM: Update Status ("Calculating...")
Main_Python->>Worker_Python: await js.worker.do_work()
Note right of Worker_Python: Heavy Data Processing
Worker_Python-->>Main_Python: Return Result
Main_Python->>DOM: Render Result
- Main Thread Python: Handles UI events, manipulates the DOM via the
jsmodule, and manages the application state. - Worker Python: Performs the "Heavy Lifting" (calculations, data processing).
- The Awaitable Bridge: Use an RPC library (like Comlink) to expose the worker to the main thread. Pyodide automatically converts JS Promises into Python Awaitables, allowing you to
awaitthe worker's result directly in Python.
import js, asyncio
from pyodide.ffi import create_proxy
async def handle_click(event):
# Update UI immediately
js.document.getElementById("status").innerText = "Calculating..."
# Offload to worker and 'wait' without blocking the UI thread
# 'worker_bridge' is a JS Proxy made available globally
result = await js.worker_bridge.do_heavy_work()
# Update UI with result
js.document.getElementById("output").innerText = str(result)
# Bind the async handler
click_proxy = create_proxy(lambda e: asyncio.ensure_future(handle_click(e)))
js.document.getElementById("btn").addEventListener("click", click_proxy)- Pros: 100% Python logic for the entire app. Responsive UI during long-running tasks. Extremely readable, linear code.
- Cons: Requires two Pyodide instances (higher memory usage). Requires a small amount of initial JS bootstrapping to set up the bridge.
- Worker RPC: The underlying communication mechanism.
- Proxy Memory Management: Important for cleaning up event listener proxies.
- Worker Pool: Can be used instead of a single worker for massive parallelism.
- Example:
examples/loading/python_ui_offloading.html - Test:
tests/patterns/architectural/test_python_ui.py