Architectural note: Using PyScript's module loading and sandboxing for secure web apps
Architectural guide for using PyScript's <py-config> module loading and sandboxing to run isolated Python in the browser, with checks, failure modes, and design triggers.
07 Dec 2025, 16:19 UTC

Problem
You need to run Python code in the browser to perform calculations or data processing, but you must keep the execution isolated from the page DOM and limit the impact of network or resource failures.
Requirements
- Load only the Python packages you actually need (e.g.,
numpy) from a trusted CDN. - Run Python inside a sandbox that prevents direct DOM access unless explicitly allowed.
- Detect loading failures, Python exceptions, and excessive memory use so you can surface errors to the user or fallback logic.
- Keep the design simple enough to avoid unnecessary complexity while still being operable in production.
Smallest suitable design
The minimal PyScript setup consists of three parts:
- A
<py-config>tag that lists the required packages. - One or more
<py-script>blocks that contain the Python logic. - A thin JavaScript shim that calls
pyodide.runPythonAsyncto forward exceptions and to expose thejsmodule when DOM interaction is needed.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PyScript sandbox demo</title>
<link rel="stylesheet" href="https://pyscript.net/latest/pyscript.css" />
<script defer src="https://pyscript.net/latest/pyscript.js"></script>
</head>
<body>
<py-config>
{
"packages": ["numpy"]
}
</py-config>
<py-script>
import numpy as np
# Example: compute a small array
arr = np.arange(5)
print('numpy version:', np.__version__)
print('array:', arr)
</py-script>
</body>
</html>
Save the file and serve it from a local web server (e.g., python -m http.server 8000) to avoid file‑origin restrictions.
Trust and data boundaries
Sandbox isolation
PyScript relies on Pyodide, which compiles CPython to WebAssembly. The WASM module runs in a separate memory space; by default it cannot call browser APIs such as document or window. Access to those objects is only possible after explicitly importing the js module:
<py-script>
from js import document
# Now safe to manipulate the DOM
document.body.innerHTML = '<p>Hello from Python</p>'
</py-script>
Content Security Policy (CSP)
If the page delivers a CSP header, PyScript requires either:
- The hash of the inline
<py-script>block (so the browser allows the generated blob URL), or - Permission for
script-src 'unsafe-eval'(less preferred).
Adding the hash preserves the sandbox while keeping the policy strict.
Operational checks
Network‑load verification
The <py-config> tag fires an onerror attribute when a package fails to fetch. You can attach a handler:
<py-config onerror="handlePyConfigError">
{
"packages": ["numpy"]
}
</py-config>
<script>
function handlePyConfigError(event) {
console.error('PyScript package load failed:', event.detail);
// Show UI fallback or retry logic
}
</script>
Python exception forwarding
Exceptions raised inside <py-script> are caught by Pyodide and reported to the browser console. To forward them to JavaScript for custom handling:
<py-script>
try:
# code that might fail
result = 1 / 0
except Exception as e:
# Send the error to JS
pyodide.runPythonAsync(`pyodide.globals.set('last_error', ${repr(e)})`)
</py-script>
<script>
// Poll or use a promise to read the error
setInterval(() => {
const err = pyodide.globals.get('last_error');
if (err) {
alert('Python error: ' + err);
pyodide.globals.set('last_error', null);
}
}, 500);
</script>
WASM memory monitoring
In Chrome DevTools → Memory tab, take a heap snapshot before and after a heavy import (e.g., pandas). Look for steady growth; if memory approaches the default ~1 GB limit, consider:
- Splitting the workload across multiple workers.
- Using lighter‑weight pure‑Python alternatives.
- Increasing the limit via
pyodide._configure({ wasmMemoryLimit: 2 * 1024 * 1024 * 1024 })(only if you control the Pyodide build).
Failure modes
- Network interruption: CDN request for a wheel fails →
onerrortriggers; the Python block never runs. - WASM memory exhaustion: Repeated allocation of large arrays → eventual
MemoryErrorin Python; monitor via DevTools. - Incompatible C‑extensions: Trying to import
lxmlorscipywithout a WebAssembly wheel → import error at runtime. - Malicious Python: Code could spin a tight loop consuming CPU or allocate large lists to exhaust memory; the WASM sandbox prevents direct DOM abuse but does not limit CPU.
Conditions that would change the design
- Frequent synchronous DOM manipulation: If the app must update the UI from Python on every animation frame, the overhead of crossing the Pyodide‑JS bridge each time becomes prohibitive. A tighter bridge (e.g., exposing a limited set of DOM helpers via
jsor moving the UI logic to JavaScript) would be preferable. - Strict CSP disallowing
unsafe-evaland without hash support: PyScript relies on blob URLs generated from the<py-script>content. If the CSP blocks those blobs, you must either host the Python code in an external file and load it viasrcattribute (still subject to CSP) or abandon PyScript for a different approach. - Offline‑first requirement: When the application must work without network access, you need to bundle the Pyodide runtime and all required wheels with the service worker. This increases initial payload and complicates version updates, shifting the design toward a pre‑bundled Pyodide build.
Practical verification steps
- Create the minimal HTML file shown above.
- Serve it locally (
python -m http.server 8000) and openhttp://localhost:8000in a browser. - Open the browser console; you should see lines like:
numpy version: 1.26.0array: [0 1 2 3 4]- To test network failure, block
https://cdn.pyscript.netin DevTools → Network → Throttling → Offline, reload, and verify that theonerrorhandler logs an error and no Python output appears. - To test memory, import
pandasin a<py-script>block, perform a large operation (e.g.,df = pd.DataFrame(np.random.rand(10000, 10))), then take a memory snapshot before and after; ensure growth stays well below the 1 GB default. - To test CSP, serve the page with header
script-src 'self'and add the SHA‑256 hash of the inline script (obtainable from the console error). Reload – the script should run. Remove the hash and reload – the console will show a CSP violation and no Python output.
Limitations
- Initial load size: Pyodide runtime (~5 MB compressed) plus any packages.
- Only pure‑Python wheels or those with WebAssembly builds are usable; many scientific packages with compiled extensions require custom builds.
- The sandbox does not protect against resource‑exhaustion attacks; additional limits (e.g., Web Workers, time‑outs) are needed for untrusted code.
- Debugging Python stack traces can be less ergonomic than native Python due to the translation layer.
By following the smallest viable design, enforcing trust boundaries through the WASM sandbox and CSP, and implementing the operational checks above, you can safely embed PyScript‑driven Python functionality in a web application while keeping failure modes visible and manageable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.