4. Running both modes¶
You built the whole counter: tree, state and
patches. Now the payoff of tempestweb's central promise: the
same examples/counter/app.py runs in Mode A (WASM) and Mode B
(server) — without changing a line. 🎯
The app does not name a transport¶
Re-read the complete app. Notice what is not there: no import websocket,
no import pyodide, no mention of "browser" or "server".
"""Counter — the canonical tempestweb example."""
from __future__ import annotations
from dataclasses import dataclass
from tempest_core import App, Button, Column, Row, Style, Text, Widget
from tempest_core.style import Edge
@dataclass
class CounterState:
"""State for the counter app."""
value: int = 0
def make_state() -> CounterState:
"""Build the initial state."""
return CounterState()
def view(app: App[CounterState]) -> Widget:
"""Render the counter UI from the current state."""
def increment() -> None:
app.set_state(lambda s: setattr(s, "value", s.value + 1))
def decrement() -> None:
app.set_state(lambda s: setattr(s, "value", s.value - 1))
return Column(
style=Style(gap=8.0, padding=Edge.all(16)),
children=[
Text(content=f"Count: {app.state.value}", key="label"),
Row(
style=Style(gap=4.0),
children=[
Button(label="-", on_click=decrement, key="dec"),
Button(label="+", on_click=increment, key="inc"),
],
),
],
)
The app is transport-agnostic
view, make_state and the handlers are 100% portable. The mode choice
is made by the CLI, outside the app. That is the project's single seam.
Mode A — Python in the browser (WASM)¶
In Mode A, your Python runs inside the tab via Pyodide. The CLI bundles the app + the JS client into a static bundle:
- Pyodide loads the Python interpreter in the browser.
view()runs in-process; thediffproduces patches.- Patches reach the client via
pyodide.ffi— a function call, no network. client/dom.jsapplies the patches to the DOM.
Mode A is fully offline
After the initial load, everything runs in the browser — no server. It is the natural target for PWA & offline. The cost is the WASM bundle cold-start (which the Track P service worker solves via precache).
Mode B — Python on the server (FastAPI)¶
In Mode B, your Python runs on the server and talks to a thin JS client over WebSocket (or SSE). Like Phoenix LiveView:
- FastAPI hosts the app; each connection has its own isolated asyncio session.
- The user clicks → the client sends
{ "kind": "event", "data": {...} }. - The server resolves the handler, runs
view(), computes thediff. - The server sends
{ "kind": "patches", "data": [...] }back. - The same
client/dom.jsapplies the patches.
The JS client is the same in both modes
Only the transport implementation differs: transport-wasm.js (Mode A) versus
transport-ws.js / transport-sse.js (Mode B). The renderer (client/dom.js,
client/style.js) is a single one.
Side by side¶
| Mode A — WASM | Mode B — Server | |
|---|---|---|
| Where Python runs | In the browser (Pyodide) | On the server (FastAPI) |
| Patch transport | pyodide.ffi (in-process) |
WebSocket / SSE (network) |
| State | In the browser | On the server, isolated per connection |
| Offline | Full after load | Partial (read-only cache + queue) |
| Latency per interaction | Zero round-trip | One network round-trip |
| SEO / first-paint | Weak (WASM bundle) | Strong (server HTML) |
Choose the mode by requirement, not taste
Need SEO, fast first-paint, or to run sensitive logic on the server? →
Mode B. Need full offline, zero server infra, or a pure-client installable
app? → Mode A. The app is the same; only --mode changes.
Recap¶
- The
app.pynever names a transport —view/state/handlers are portable. - Mode A runs Python in the browser via Pyodide; patches via
pyodide.ffi. - Mode B runs Python on the server (FastAPI); patches via WebSocket/SSE.
- The JS client and renderer are the same; only the transport impl changes.
🎉 You finished the tutorial! You built the counter and understand the wire contract end to end. To go further, explore the native capabilities, the PWA & offline layer and observability.