Offline + backend sync¶
What you'll learn
How to wire tempestweb's offline queue (the client) to a FastAPI backend
built with tempest-fastapi-sdk
for an end-to-end offline-first app: the app queues mutations locally when
there's no network and syncs them when connectivity returns — without ever
applying anything twice. 🚀
The PWA & offline page covers the offline queue on the client side
(service worker, IndexedDB, native.offline). Here we close the loop: the other
end of the mutation is a real server that receives, persists and dedups the
replays. Nothing new in your view() — just a well-built endpoint on the far
side. ✅
Why offline-first¶
A traditional app writes straight to the network: no network, the write fails and the user loses what they typed. Offline-first flips that around:
- Queue locally first. Every mutation goes into a durable IndexedDB queue — the write "succeeds" immediately, even with no network.
- Sync when the network returns. When connectivity is back (the
onlineevent, Background Sync, or an explicit replay), the queue drains in FIFO order against the backend. - Never apply twice. Each mutation carries an idempotency key; the server dedups, so re-sending an already-applied mutation is harmless.
Two directions, two mechanisms
- Push (writes) — app → server. This is what the
native.offlinequeue does for you: enqueue, replay, idempotency. - Pull (reads) — server → app. There's no queue for this; it's a
delta-sync pattern you assemble with
native.http+ the repository'supdated_at__gtfilter. We cover both below.
Architecture¶
┌──────────────────────── Browser (tempestweb app) ─────────────────────────┐
│ │
│ view() ──enqueue──► native.offline ──► OfflineQueue ──► IndexedDB │
│ (Python) (durable write) (FIFO + key) (tempestweb- │
│ offline / │
│ mutations) │
│ │
│ online / Background Sync / replay() ──drains FIFO──► fetch │
│ header: │
│ idempotency-key │
└─────────────────────────────────────────────────────────────┬────────────┘
│ HTTPS
▼
┌──────────────── Backend (FastAPI + tempest-fastapi-sdk) ───────────────────┐
│ │
│ IdempotencyMiddleware ──(method, path, key)──► store (Memory | Redis) │
│ │ 1st time │ retry / replay → cached response │
│ ▼ ▼ │
│ router ──► BaseRepository.add() ──► Postgres / SQLite │
│ (id, created_at, updated_at automatic) │
│ │
│ pull: GET /api/notes?since=<ts> ──► repo.list({"updated_at__gt": ts}) │
└────────────────────────────────────────────────────────────────────────────┘
The meeting point is the idempotency key: the client sends it in a header, and
the SDK's IdempotencyMiddleware uses it to cache the response by
(method, path, key). A repeated replay returns the cached response — zero
duplicate rows in the database.
A separate backend, on purpose
The backend is its own FastAPI service (the same one you'd already have
with the SDK). It doesn't need to know the client is a tempestweb app — it
receives a normal POST/PUT with an idempotency header. This holds across
all three tempestweb execution modes (WASM, server, transpile).
Part A — Enqueue mutations in the tempestweb app¶
The native.offline capability is the real client API. You enqueue a mutation
instead of hitting the network directly:
from tempestweb import native
async def save_note(text: str) -> None:
"""Queue a note creation (survives being offline)."""
await native.offline.enqueue("POST", "/api/notes", {"text": text})
The full signature:
await native.offline.enqueue(
method, # "POST" | "PUT" | "PATCH" | "DELETE"
url, # your backend endpoint
body=None, # JSON-able body
*,
idempotency_key=None, # auto-generated when omitted
owner=None, # optional scope (e.g. per user)
)
It returns a Mutation (id, owner, idempotency_key, method, url,
attempts, status). status is "pending" until it drains; a mutation that
fails permanently becomes "failed" (dead-letter) and a 409 conflict becomes
"conflict" — neither wedges the queue. You can also inspect and drain
the queue:
| Call | Does | Returns |
|---|---|---|
native.offline.enqueue(...) |
Queue a durable mutation. | Mutation |
native.offline.pending(owner=None) |
List pending ones, oldest first. | list[Mutation] |
native.offline.size(owner=None) |
Count the pending ones. | int |
native.offline.replay(owner=None) |
Drain the queue now (FIFO). | ReplayResult(sent, remaining, failed, conflicts) |
native.offline.failed(owner=None) |
List the dead-lettered mutations (permanent failure / attempts exhausted). | list[Mutation] |
native.offline.conflicts(owner=None) |
List mutations parked in the conflict lane (server returned 409). |
list[Mutation] |
The full app¶
A minimal "activity log": typing and pressing Queue writes to the queue; the
counter comes from size(); Sync triggers the replay (which the runtime also
does on its own when the network returns). This is
examples/offline-queue/app.py:
from __future__ import annotations
from dataclasses import dataclass, field
from tempest_core import App, Style, Widget
from tempest_core import Edge
from tempest_core import Button, Column, Input, Row, Text
from tempest_core import TextChangeEvent
from tempestweb import native
@dataclass
class QueueState:
"""State for the offline-queue demo."""
draft: str = ""
queued: int = 0
status: str = ""
log: list[str] = field(default_factory=list)
def make_state() -> QueueState:
"""Initial state."""
return QueueState()
def view(app: App[QueueState]) -> Widget:
"""Input + pending count + replay control."""
def on_draft(event: TextChangeEvent) -> None:
value = event.value
app.set_state(lambda s: setattr(s, "draft", value))
async def queue_note() -> None:
text = app.state.draft
await native.offline.enqueue("POST", "/api/notes", {"text": text}) # (1)!
size = await native.offline.size()
def _update(s: QueueState) -> None:
s.queued = size
s.draft = ""
s.log = [*s.log, text]
s.status = f"queued: {text}"
app.set_state(_update)
async def sync_now() -> None:
result = await native.offline.replay() # (2)!
def _update(s: QueueState) -> None:
s.queued = result.remaining
s.status = f"sent {result.sent}, {result.remaining} left"
app.set_state(_update)
return Column(
style=Style(gap=10.0, padding=Edge.all(16)),
children=[
Text(content=f"Pending: {app.state.queued}", key="pending"),
Row(
style=Style(gap=6.0),
children=[
Input(
value=app.state.draft,
placeholder="a note to sync",
on_change=on_draft,
key="draft",
),
Button(label="Queue", on_click=queue_note, key="queue"),
Button(label="Sync", on_click=sync_now, key="replay"),
],
),
Text(content=app.state.status, key="status"),
],
)
- Queues instead of
fetch-ing. Works with no network — the write goes to IndexedDB. - Drains the queue now. The runtime also does this on its own when the
network returns (
onlineevent + Background Sync), so this button is optional.
The idempotency key is automatic
You don't have to generate the key: when you omit idempotency_key, the queue
generates a stable one and sends it in the idempotency-key header on every
replay. That key is what the backend uses to avoid applying the same write
twice.
Part B — The sync endpoint on the backend with the SDK¶
On the far side, a FastAPI service built with tempest-fastapi-sdk. Three pieces:
the model, the schemas and the endpoint — plus the
IdempotencyMiddleware that makes replay safe.
The model and the schemas¶
The SDK's BaseModel already ships id (UUID), is_active, created_at and
updated_at — the last auto-refreshed on every UPDATE, which is exactly
what delta-sync (Part E) needs.
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel, BaseResponseSchema, BaseSchema
class NoteModel(BaseModel):
"""A persisted note."""
__tablename__ = "notes"
text: Mapped[str] = mapped_column()
class NoteCreateSchema(BaseSchema):
"""Note creation body (what the client queues)."""
text: str
class NoteResponseSchema(BaseResponseSchema):
"""Note response — inherits id/is_active/created_at/updated_at."""
text: str
BaseRepository with no subclass
For plain CRUD you instantiate BaseRepository directly:
BaseRepository(session, model=NoteModel). It exposes add(), list(),
get_by_id(), update(), delete() and more. Only subclass it when you need
custom queries.
The FastAPI app¶
from __future__ import annotations
from collections.abc import AsyncGenerator
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseRepository,
IdempotencyMiddleware,
MemoryIdempotencyStore,
register_exception_handlers,
)
db = AsyncDatabaseManager("sqlite+aiosqlite:///./notes.db")
app = FastAPI(title="notes-backend")
# Dedup replays: a repeated POST with the same Idempotency-Key returns the cached
# response — no duplicate row.
app.add_middleware(
IdempotencyMiddleware,
store=MemoryIdempotencyStore(), # swap for RedisIdempotencyStore on multi-replica
ttl_seconds=24 * 3600,
)
register_exception_handlers(app)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""One async session per request."""
async for session in db.session_dependency():
yield session
@app.on_event("startup")
async def _startup() -> None:
"""Create the tables on boot (use migrations in production)."""
await db.create_tables()
@app.post("/api/notes", response_model=NoteResponseSchema, status_code=201)
async def create_note(
data: NoteCreateSchema,
session: AsyncSession = Depends(get_session),
) -> NoteResponseSchema:
"""Persist a note. Idempotent via the Idempotency-Key header."""
repo = BaseRepository(session, model=NoteModel)
note = await repo.add(NoteModel(text=data.text))
return NoteResponseSchema.model_validate(note)
How idempotency connects end to end
The native.offline replay sends the idempotency-key header; the
IdempotencyMiddleware reads Idempotency-Key — and HTTP headers are
case-insensitive, so the two match with no work on your side. The
middleware only acts on POST/PUT/PATCH/DELETE and only when the
header is present; requests without a key pass straight through.
Part C — Replay and confirmation when back online¶
Replay is the client runtime's responsibility — the SDK imposes no sync protocol. tempestweb drains the queue on three triggers:
- The
onlineevent. As soon as the browser reports connectivity, the queue drains (tab open). -
Background Sync. Where the browser supports it (Chromium), the service worker drains the queue even with the tab closed.
enqueueregisters the tag (tw-offline-replay) itself, so there is nothing to wire in your app.Measured in a real Chrome, with the tab closed
Two mutations queued offline, tab closed, network back: both left 1.01 s after the tab closed and 3 ms after reconnect, with no page of that origin open, and the IndexedDB queue came back empty. Until 0.110.0 this did not happen: the worker reached its queue modules with a dynamic
import(), which no service worker may do — the spec forbids it onServiceWorkerGlobalScope— so everysyncfell into the ping-a-client fallback, and with the tab closed there is no client to ping. The queue silently stayed put.- Explicit. Your app calls
await native.offline.replay()whenever you want (e.g. a "Sync" button or when a screen opens).
- Explicit. Your app calls
Replay is FIFO and classifies each failure, without letting the queue wedge:
- Transient failure (network error,
5xx,408/425/429): the mutation stays pending (withattemptsincremented) and replay stops there, preserving order. AftermaxAttemptstries (default 5) it is dead-lettered (status="failed") and replay keeps draining the rest. - Permanent error (non-retryable
4xx, e.g.400/422): dead-lettered on the first attempt — re-sending a body the server always rejects is pointless, so it never blocks the queue. - Conflict (
409): the mutation moves to the conflict lane (status="conflict") for you to reconcile (Part D); it does not block either.
So a single "poison message" never holds every write behind it. The
ReplayResult tells you the outcome (sent, remaining, failed, conflicts):
from tempestweb import native
async def sync_and_report() -> str:
"""Drain the queue and return a human-readable status line."""
result = await native.offline.replay()
if result.remaining == 0:
return f"all synced ({result.sent} sent)"
return f"sent {result.sent}, {result.remaining} still pending"
Replay doesn't return the created resource to the app
replay() returns only counts (sent / remaining), not the server's
response bodies. So the app doesn't get back the id/created_at of a note
created by a queued mutation. If you need that data on the client, re-read
it from the server with a pull (Part E) after the replay — the canonical
offline-first pattern (write optimistically, reconcile by re-reading).
Part D — Idempotency and conflict¶
Idempotency (solved)¶
The IdempotencyMiddleware caches the response by (method, path, key). A replay
with the same key returns the same result — never inserts twice. It's the
Stripe/AWS/GitHub pattern, and it's what makes replay safe by construction.
Pick the right store in production
MemoryIdempotencyStore is per-process — on multi-replica one replica
can't see the key cached by another, and replay can duplicate. With more than
one replica, use RedisIdempotencyStore:
Conflict (your call)¶
Idempotency solves duplicate replay, not content conflict (two clients editing the same row). tempestweb's queue default is last-write-wins: the last write to arrive wins. For most apps (notes, logs, forms) that's enough.
If you need version-based resolution (rejecting a write based on a stale version), that's an application decision — neither tempestweb nor the SDK imposes a protocol. A common pattern is to version the row and refuse stale writes:
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@app.put("/api/notes/{note_id}", response_model=NoteResponseSchema)
async def update_note(
note_id: str,
data: NoteUpdateSchema, # includes a `version: int` field
session: AsyncSession = Depends(get_session),
) -> NoteResponseSchema:
"""Update a note, rejecting writes based on a stale version."""
repo = BaseRepository(session, model=NoteModel)
note = await repo.get_by_id(note_id) # 404 if missing
if data.version < note.version: # conflict → the client re-reads
raise HTTPException(status_code=409, detail="stale version")
note.text = data.text
note.version = data.version + 1
return NoteResponseSchema.model_validate(await repo.update(note))
When the server answers 409, the client neither drops nor retries in a loop
the mutation: it leaves the pending set and moves to the conflict lane. Replay
keeps draining the rest of the queue and the app reconciles later by reading that
lane:
from tempestweb import native
async def reconcile_conflicts() -> None:
"""Re-read and reconcile the mutations the server rejected with 409."""
for m in await native.offline.conflicts():
fresh = await native.http.request("GET", m.url) # re-read current state
... # merge/UI at your discretion
Honesty about the boundary
The endpoint snippet above is application code, not a framework API — the
version field is yours. tempestweb delivers the mutation idempotently and
parks the 409 in the conflict lane for you; how you reconcile is up to
you (last-write-wins, version, per-field merge, CRDT…).
Part E — Pull: bringing server changes back to the app¶
The queue handles the push. For the pull (the server has new data the app
hasn't seen yet), the pattern is delta-sync: keep a watermark (the largest
updated_at you've seen) and ask only for what changed since then.
BaseRepository supports the updated_at__gt filter for exactly this:
from datetime import datetime
from fastapi import Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
@app.get("/api/notes", response_model=list[NoteResponseSchema])
async def list_notes(
since: datetime | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[NoteResponseSchema]:
"""List the notes changed since `since` (delta-sync)."""
repo = BaseRepository(session, model=NoteModel)
filters = {"updated_at__gt": since} if since else None
notes = await repo.list(filters=filters, order_by=NoteModel.updated_at)
return [NoteResponseSchema.model_validate(n) for n in notes]
On the client, you read the delta with native.http (not the queue — reads don't
need a durable queue) and advance the watermark:
from tempestweb import native
async def pull_since(watermark: str | None) -> tuple[list[dict], str | None]:
"""Fetch notes changed since `watermark`; return (notes, new_watermark)."""
url = "/api/notes" + (f"?since={watermark}" if watermark else "")
response = await native.http.request("GET", url)
if not response.ok:
return [], watermark
notes = response.json_body or []
newest = max((n["updated_at"] for n in notes), default=watermark)
return notes, newest
The pull engine (client/offline/pull.js)¶
You don't have to hand-roll the delta-sync loop: the client ships a ready-made
engine (pure JS), mirroring the tempest-react-sdk pattern.
createPull({ pullPage, applyRemote, watermark })— walks the pages since the watermark followingnextCursor, applies each row in order, and advances the watermark to the latestserverTimeonly after a full drain (an interrupted pull re-reads from the last committed point). Single-flight, like the queue.createWatermark(key, storage)— a durablelocalStorage-backed cursor with an in-memory fallback.mergeRemoteInto(store, opts)— the canonical merge over theOfflineStore: last-write-wins, tombstone deletes, and a guard that keeps a locally-pending edit that is strictly newer than the incoming server row (so an unpushed offline write survives a pull).
import { createPull, createWatermark, mergeRemoteInto } from "/client/offline/pull.js";
const pull = createPull({
pullPage: async (since, cursor) => {
const qs = new URLSearchParams();
if (since) qs.set("since", since);
if (cursor) qs.set("cursor", cursor);
const res = await native.http.request("GET", `/api/notes?${qs}`);
const body = res.json_body || {};
return { rows: body.rows, nextCursor: body.next_cursor, serverTime: body.server_time };
},
applyRemote: mergeRemoteInto(store), // store = createOfflineStore(...)
watermark: createWatermark("notes:watermark"),
});
await pull.pull(); // drains everything since the last watermark
To combine push + pull in one gesture with observable state, use
createSyncController({ queue, pull }) (client/offline/sync-status.js):
syncNow() replays the queue then pulls, single-flight, and reflects
phase/pending/lastSyncedAt/error in an observable store (subscribe) your
view() can render. And installSyncBridge() (client/offline/sw-bridge.js)
wires the service-worker messages (OFFLINE_PULL, OFFLINE_QUEUE_DRAINED) to
that controller — after the worker drains the queue in the background, the page
(which holds the token) reconciles with a pull.
From Python: the native.sync capability¶
You don't have to touch JS: native.sync exposes all of this to your
view() (like native.network does for connectivity). You configure a named
source (endpoint + local table, convention GET <url>?since=&cursor= →
{rows, next_cursor, server_time}); installSyncBridge is wired automatically on
the first configure (the SW's OFFLINE_PULL reconciles every configured source).
from tempestweb import native
await native.sync.configure("notes", "/api/notes", "app-db", "notes")
summary = await native.sync.now("notes") # replay the queue + pull
state = await native.sync.status("notes") # SyncState(phase, pending, last_synced_at, ...)
async for s in native.sync.watch("notes"): # stream the state (T-EV)
app.set_state(lambda st: setattr(st, "sync", s))
Recap¶
- Push is handled by the queue.
native.offline.enqueue(...)writes locally; the runtime drains FIFO when the network returns (online+ Background Sync) or viareplay(). Each mutation carries an idempotency key. - The queue never wedges. A transient failure retries up to
maxAttemptsthen becomes a dead-letter (native.offline.failed()); a permanent4xxis dead-lettered immediately; a409moves to the conflict lane (native.offline.conflicts()). One poison message won't hold the rest. - The backend is FastAPI + the SDK.
BaseRepository.add()persists; theIdempotencyMiddleware(headerIdempotency-Key, case-insensitive) dedups replays by(method, path, key)— zero duplicate rows. - Conflict is your call. The default is last-write-wins; the conflict lane
hands you the
409to reconcile — version/merge is application code. - Pull has a ready-made engine.
createPull+mergeRemoteInto+createWatermark(client/offline/pull.js) do the delta-sync (watermark + cursor + LWW) over theOfflineStore— you only supplypullPage(vianative.http+ the repository'supdated_at__gtfilter).createSyncControllercombines push+pull with observable state;installSyncBridgewires the SW to the page.
Ready to ship the backend? See Deploy to production and Security (Mode B). 🚀