Skip to content

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:

  1. Queue locally first. Every mutation goes into a durable IndexedDB queue — the write "succeeds" immediately, even with no network.
  2. Sync when the network returns. When connectivity is back (the online event, Background Sync, or an explicit replay), the queue drains in FIFO order against the backend.
  3. 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.offline queue 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's updated_at__gt filter. 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"),
        ],
    )
  1. Queues instead of fetch-ing. Works with no network — the write goes to IndexedDB.
  2. Drains the queue now. The runtime also does this on its own when the network returns (online event + 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.

uv add "tempest-fastapi-sdk[cache]"   # [cache] only if you'll use the Redis store

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 online event. 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. enqueue registers 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 on ServiceWorkerGlobalScope — so every sync fell 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).

Replay is FIFO and classifies each failure, without letting the queue wedge:

  • Transient failure (network error, 5xx, 408/425/429): the mutation stays pending (with attempts incremented) and replay stops there, preserving order. After maxAttempts tries (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:

from redis.asyncio import Redis
from tempest_fastapi_sdk import IdempotencyMiddleware, RedisIdempotencyStore

app.add_middleware(
    IdempotencyMiddleware,
    store=RedisIdempotencyStore(Redis.from_url("redis://localhost:6379/0")),
    ttl_seconds=24 * 3600,
)

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 following nextCursor, applies each row in order, and advances the watermark to the latest serverTime only after a full drain (an interrupted pull re-reads from the last committed point). Single-flight, like the queue.
  • createWatermark(key, storage) — a durable localStorage-backed cursor with an in-memory fallback.
  • mergeRemoteInto(store, opts) — the canonical merge over the OfflineStore: 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 via replay(). Each mutation carries an idempotency key.
  • The queue never wedges. A transient failure retries up to maxAttempts then becomes a dead-letter (native.offline.failed()); a permanent 4xx is dead-lettered immediately; a 409 moves to the conflict lane (native.offline.conflicts()). One poison message won't hold the rest.
  • The backend is FastAPI + the SDK. BaseRepository.add() persists; the IdempotencyMiddleware (header Idempotency-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 409 to 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 the OfflineStore — you only supply pullPage (via native.http + the repository's updated_at__gt filter). createSyncController combines push+pull with observable state; installSyncBridge wires the SW to the page.

Ready to ship the backend? See Deploy to production and Security (Mode B). 🚀