Skip to content

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:

tempestweb build --mode wasm examples/counter/app.py
tempestweb dev   --mode wasm examples/counter/app.py   # with hot-reload
  1. Pyodide loads the Python interpreter in the browser.
  2. view() runs in-process; the diff produces patches.
  3. Patches reach the client via pyodide.ffi — a function call, no network.
  4. client/dom.js applies 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:

tempestweb dev --mode server examples/counter/app.py
# serves at http://127.0.0.1:8000
  1. FastAPI hosts the app; each connection has its own isolated asyncio session.
  2. The user clicks → the client sends { "kind": "event", "data": {...} }.
  3. The server resolves the handler, runs view(), computes the diff.
  4. The server sends { "kind": "patches", "data": [...] } back.
  5. The same client/dom.js applies 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.py never names a transportview/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.