Skip to content

Request & data flow

Chapter 4. You know the pieces and how stores stay apart. Now follow a single request through them — a shopper loading a page, and a merchant publishing a change.

Two journeys explain how the system behaves: a shopper loading a page, and a merchant publishing a change.

Shopper → HTML (the read path)

Two things the diagram is careful about:

  • The KV lookup has three outcomes, and "not found" is really two different things:

    KV outcomeStored under host:{host}What happensDB hit?
    Found{ "t": "tenant_123" } (1h)use the tenantId, carry onNo
    Known-bad{ "t": null } (60s)branded 404 straight awayNo
    Not in KVkey absentask the DB once, then cache backYes (once)

    The known-bad cache is what stops a flood of bad-host requests (typos, scanners) from hammering Postgres; real stores are also pre-warmed into KV on domain verify, so a genuine first request is normally a hit.

  • "Cache miss" means no fresh copy — not an empty cache. An expired copy may still be present, kept on purpose so there's something to serve if the origin later dies. Order of preference: fresh in cache → fresh from origin → stale from cache → branded 503.

At the edge — apps/edge/src/worker.ts (logic in packages/edge-core)

  1. Resolve tenanthost → tenantId from Workers KV (TENANTS), with a DB fallback that only matches a verified domain (domains WHERE host=… AND verified=true). No tenant → 404 "Store not found".
  2. Guard — block internal /__* paths; require ORIGIN_URL (else a branded 503 — the edge never renders).
  3. Serve or proxy — serve from caches.default on a hit; on a miss, proxy to the origin with a signed x-edge-auth + x-ratio-tenant, behind a per-isolate circuit breaker, and cache the origin response.
  4. Sanitize — strip internal x-* headers before the client; emit an access log + a metrics point.

At the origin — apps/origin/src/index.ts (one big app.all('*'))

The pipeline, in order (order matters — auth and reserved-path gates come before rendering):

  1. correlation id + tracing + access log
  2. /health, /ready (unauthenticated probes)
  3. edgeAuthOk(...) — the origin is private; fail closed 403 without the edge secret
  4. /assets/*, /__stats, well-known / PWA manifest
  5. reserved-path handling: /account login gate, swallow /checkout · /api/*, with carve-outs
  6. forTenant(tenantId).getTenant() → 404 if missing/suspended
  7. /robots.txt, /sitemap.xml (via @ratio/seo)
  8. /api/island/* — the only per-user path (an island: a cached shell carries an inert placeholder that's hydrated client-side; defined in Rendering & themes), always no-store
  9. interactive commerce: /cart*, /order-confirmation, /account*, /search — all no-store
  10. POST /checkout — the GoKwik handshake
  11. fall-through → renderStorefront(...) — the cacheable page

The page render — apps/origin/src/handlers/storefront.ts

The bundle-theme path (primary, when the tenant has a live bundle theme):

  1. matchRoute(path) → pick the template (home / collection / product / page / …).
  2. themeStore.loadLiveCompiled(tenantId) — load the compiled bundle from S3 (in-memory LRU). A structural miss or a broken layout fails loud (500) so the edge serves stale — never a silent cached 404.
  3. Render, in parallel: the page sections (renderThemePage, each theme section via the untrusted isolate; each first-party section via the registry), the chrome (header/footer groups), and the SEO head.
  4. Resolve integrations (composeStorefront → cart / checkout / search head + body + CSP).
  5. Wrap it all in the theme's own layout/theme.liquid (renderThemeLayout) — the theme owns the whole document.
  6. Emit surrogate cache tags (x-surrogate-keys = tenant + page + data + menu tags) and cache-control: public, s-maxage=300, swr=86400 + a strict CSP.

If there's no live bundle theme, it degrades to the legacy page-builder path (pageStore.getLivecomposePage) with the same tags / CSP model.

How data reaches a section — the resolver seam

A page template declares dataSources (e.g. COLLECTION_BY_HANDLES { handles: ['{{params.handle}}'] }). At render:

  • resolvePage(doc, registry, resolver, ctx) interpolates route params, fan-out fetches (concurrency 6), and injects each result into its section's binding namespace.
  • Each fetch returns cache tags (col:*, prod:*, menu:<handle>) that become the page's surrogate keys — so when that product/collection changes, exactly the right pages purge.
  • The resolver is injected: real ShopkitResolver (over the GoKwik backend) when configured, else StubResolver (sample data for local dev; production throws rather than serve samples). See Commerce.

When things fail — the resilience path

Most page-views never reach the origin (a cache hit). But when the origin or the DB is slow or down, the edge must still serve something. It degrades in tiers instead of erroring:

  • T1 — origin down/slow: a per-isolate circuit breaker trips; the edge serves the last-good cached page (marked x-ratio-stale) instead of failing. This is why a broken render must fail loud (500) at the origin — so the edge serves stale rather than caching a bad page.
  • T2 — Postgres down: tenant routing still works because the edge reads host → tenantId from KV, which survives a DB outage (the DB is only hit on a KV miss).
  • Last resort: no cached copy to fall back on → a branded 503, never a raw error.
  • Hard limit: writes (/cart, /checkout) can't be faked from cache — they need the origin + Postgres up.

Merchant → live (the write path)

Two surfaces edit the same draft; an owner then publishes it live.

The edit loop — authoring a draft

A merchant edits in the code editor (a Monaco file tree) or the visual customizer (a live-preview iframe with in-canvas selection). Both write the same draft bundle. The customizer loop:

  • The data-rt-* selection markers are injected only in editor mode — they never reach a live storefront.
  • The code editor path is simpler: open a file → edit → PUT draft (the same saveDraft + CAS), with live preview.

Publish — draft to live

Publish is owner-only and atomic — nothing changes unless validation passes:

  1. Edit — the code editor or the visual customizer edits the theme's files (both write the same draft bundle via the admin API).
  2. SavePUT .../draftThemeStore.saveDraft with optimistic concurrency (a 409 if another editor saved first). Draft = a mutable source bundle in S3.
  3. PreviewPOST .../preview renders the in-flight buffer through the same render path (the customizer sets an editor flag that wraps sections with selection markers — editor-only, never on the live storefront).
  4. Publish (owner-only) — compile + minify, validate (full-document layout, required templates, section references), freeze immutable published/{source,compiled} bundles, and flip the live pointer (tenants.live_theme_id/version) in Postgres.
  5. Invalidate — purge the tenant's edge cache and warm the new version.

Base propagation (improve the shared base, roll it into stores) rides the same primitives: republish-base cuts a new base version from code → rebase-to-latest-base moves each store onto it, preserving its overrides (base ⊕ overrides is a file-level merge). See base propagation runbook.

Interactive shopper flows

The read path is cacheable HTML. Everything a shopper does — cart, login, checkout, account, search — is per-user and no-store, handled by the origin's commerce handlers (apps/origin/src/handlers/). See Commerce for the seams behind these.

Add to cart

The drawer is the cart (there's no cart page). Three cookies, easy to conflate — an httpOnly server cart, a JS-readable X-Cart-Token mirror for the widget, and an rt_cart_present marker.

Login (KwikPass) & account

Login (KwikPass) and checkout are independent toggles — a store isn't switched to phone login just because it can sell.

Checkout

GET /search?q=… (and a header focus-popup) hits search.ts behind the activeSearch provider seam — native product search, no-store, no JavaScript required.

Platform flows

Cache invalidation — how a change reaches shoppers

Every cached page carries surrogate tags (tenant · page · prod:* · col:* · menu:*). A change purges exactly the tags it touches, so only the affected pages refresh — not the whole store:

The durable outbox (page_purge_outbox) means a purge is never lost if the purge call fails — it's retried.

What actually purges today

Cloudflare cache-tag purge (what the diagram shows) is an Enterprise feature and needs a tag→URL index we don't have yet. So today the platform purges by URL on publish, and the commerce webhook's tag purge is a no-op in prod (it returns the tags with invalidated: []). The tag design above is the intended model; per-URL purge is what runs now. (See Control plane · webhook and PWA · purge.)

Onboarding — a new store

Seconds, no repo/build/deploy — a store is rows + a seeded bundle. The store is live on the default theme from creationstatus is active from the start (pending / suspended exist in the column but no code writes them; the wizard's later steps adopt a chosen base and republish). See Admin web · onboarding.

Every flow at a glance

FlowWhoDetail
Page load (read path)shopperthis page ↑
Add to cartshopperthis page ↑ · Commerce
Login (KwikPass) & accountshopperthis page ↑ · Commerce
Checkoutshopperthis page ↑ · Commerce
Searchshopperthis page ↑ · Commerce
Island hydrationshopperRendering & themes
Edit loop (customizer)merchantthis page ↑
Publishmerchant/ownerthis page ↑
Onboarding (new store)merchantthis page ↑
Commerce connectmerchantCommerce
Custom domain connectmerchantInfrastructure
AI assistantmerchant/agentControl plane & AI
Cache invalidationplatformthis page ↑
KV sync (host→tenant)platformInfrastructure
Base propagationplatformRunbooks
Deploy (canary)platformRunbooks