The Mode A boot payload¶
In Mode A your Python runs inside the browser, on Pyodide. Before the first
paint, the browser downloads tempestweb-pkg.zip, unpacks it into Pyodide's
virtual filesystem and runs import app. This page shows the two decisions
tempestweb build --mode wasm makes about that zip — and how you tune each one
in tempestweb.toml.
One-line summary
By default the zip carries bytecode precompiled for Pyodide's exact
Python and only the tempestweb subpackages your app reaches. Both fail
safe: without the right Python on the build host, source ships (with a
warning); if the import walk cannot see something, you force it with keep.
The problem: compiling everything, on every visit¶
Pyodide turns on sys.dont_write_bytecode. In practice, every module is
compiled from source on every page load — the __pycache__ CPython normally
writes never exists there, and nothing survives a reload.
Measured on the tempest-webtunnel dashboard (Pyodide v314.0.0, in Node, same
zip): with the old zip, 159 modules of the app, tempestweb and
tempest_core went through the compiler on every boot. With bytecode in the
zip, zero.
1. Bytecode in the zip — [wasm] bytecode¶
| Value | What goes in the zip | When to use |
|---|---|---|
"alongside" (default) |
.py and a __pycache__/<module>.cpython-314.pyc beside it |
Almost always. Tracebacks keep the source line, and the boot skips compilation. |
"sourceless" |
Only the .pyc, in place of the .py |
When download size matters more than tracebacks with source. |
"off" |
Only .py, as before |
To turn the feature (and the warning below) off. |
app.py is special: the bootstrap fetches it separately and writes it next to
the zip, so its bytecode travels as __pycache__/app.cpython-314.pyc inside the
zip — and Pyodide's importlib finds it there.
Why it is safe¶
A .pyc is valid for one CPython. One from another version is rejected —
or, worse, misread. So the build does not trust "looks like 3.14":
- The target is pinned in
tempestweb.core.constants:WASM_PYODIDE_PYTHON = "3.14"andWASM_PYODIDE_PYC_MAGIC(the magic number read from the Pyodide v314.0.0 runtime itself). - The build looks for an interpreter with exactly that magic: the one
running the build, then
uv python find 3.14, thenpython3.14onPATH. A 3.14 alpha has a different magic and is refused. - It compiles in checked-hash mode (PEP 552): Pyodide checks the source
hash before using the
.pyc. If a newapp.pyarrives with an old zip (caching), the module is recompiled instead of running old code. - It checks the header of every
.pycwritten. Any mismatch discards all of the bytecode.
Without a matching interpreter, the build does not fail: it emits a
WasmBytecodeWarning and packs source only — the app boots the same, just
slower.
Installing Pyodide's Python on the build host
If your environment runs another Python (3.11, 3.12, 3.13), make a 3.14 available for the build to find:
uv python find only looks — the build never downloads a Python on its
own.
tempestweb dev does not compile
The development loop rebuilds on every save and would rather be fast: it
packs source only. Bytecode is for build, run and deploy.
2. Only what the app reaches — [wasm] trim¶
The zip used to carry every browser-side tempestweb subpackage
(tabular, vision, query, export, access, ...), imported or not. Now
the build walks the imports statically and leaves out what nothing reaches.
[wasm]
trim = true # default
keep = ["tabular"] # escape hatch: subpackages that ship with no visible import
The walk starts in three places — app.py, your [wasm] modules and
tempestweb.runtime (which the bootstrap imports) — and follows each reached
subpackage. On purpose, it counts more than Python executes:
importandfrom ... importanywhere in the file — inside functions and underTYPE_CHECKINGtoo;- relative imports (
from ..tabular import x); tempestweb.htmlas an attribute;- dotted strings, as in
importlib.import_module("tempestweb.transports.sse").
Docstrings do not count: mentioning tempestweb.tabular in documentation is not
importing it.
What the walk cannot see
A module name built at runtime — import_module(f"tempestweb.{name}") —
appears nowhere in the source. If your app does that, tell the build:
keep = ["tabular"], or switch it off entirely with trim = false. A wrong
name in keep fails the build with the list of valid ones instead of being
ignored.
tempest_core always ships whole: its __init__ imports nearly everything up
front, so dropping files there would break the boot.
How much it changes — measured¶
The tempest-webtunnel dashboard (app.py + the api/ client generated by
gen api), tempestweb build --mode wasm:
| Zip | Size | Entries | .py |
.pyc |
|---|---|---|---|---|
| Before (everything, source only) | 487 KB | 198 | 197 | 0 |
bytecode = "off", trim = true |
416 KB | 168 | 167 | 0 |
Default (alongside + trim) |
1.16 MB | 336 | 167 | 168 |
sourceless + trim |
739 KB | 169 | 0 | 168 |
The trim removed access, components, export, query, tabular and
vision from this app. Bytecode costs download — .pyc compresses worse than
source — and in exchange the boot stops compiling.
And the import app time (the Python side of the boot, after the zip is
downloaded), on the same Pyodide v314.0.0 running in Node on a desktop, 10
interleaved rounds per variant on a loaded machine — median (minimum):
| Zip | import app |
|---|---|
| Before | 1454 ms (1267) |
off + trim |
1436 ms (1168) |
Default (alongside + trim) |
1247 ms (1068) |
sourceless + trim |
1176 ms (1016) |
That is ~15–20% of the Python side. The rest is not compilation: profiled,
about two thirds of import app is pydantic building the ~260 models of
tempest_core and the app — work no bytecode avoids.
What about Pyodide's memory snapshot?
Pyodide has a private memory-snapshot API
(loadPyodide({ _makeSnapshot: true }) → makeMemorySnapshot() →
loadPyodide({ _loadSnapshot })) that would skip the interpreter boot and
the imports in one go. We tried it on v314.0.0 and it cannot be used
here:
- any
loadPackage— includingpydantic, which the core requires — leaves JS-side state andmakeMemorySnapshot()refuses (Unexpected hiwire entry at index 6); - installing the wheels by hand to work around that, the snapshot is made
(36 MB) but the restore fails (
RangeError: offset is out of bounds): the nativepydantic_coreextension is a dynamic library loaded outside the memory the snapshot captures; - only an interpreter without native extensions restores (≈30 MB, in
~80 ms) — and
pydanticis exactly what makes the boot expensive.
Without pydantic in the snapshot there is no gain that pays for a 30 MB
download, so the feature was left out.
Recap¶
- Pyodide does not keep bytecode: with no
.pycin the zip, every module compiles on every visit. [wasm] bytecode="alongside"(default) ·"sourceless"·"off". The.pyconly comes from an interpreter with the pinned Pyodide's exact magic; without one, source +WasmBytecodeWarning.[wasm] trim = true(default) leaves out the subpackages the import walk does not reach;keep = [...]forces,trim = falseswitches it off.tempestweb devpacks source only, so the loop stays fast.