← Back to the course

This is the production spec — the contract the course builds toward. The guided course teaches you to reach exactly this runnable result. Skim it if you'd rather build straight from the target.

Project Spec — Ticker

Single source of truth for the Ticker course (src/content/projects/ticker.mdx). The course teaches toward exactly this runnable project. If a step and this spec disagree, fix both — prefer fixing the spec first. Spotlight: Redis Streams (realtime change feed + fan-out + reconnect-replay). Backends: Go (default) and TypeScript (Hono on Bun) — both implement the identical contract below.


1. Overview & definition of done

Ticker is the realtime backbone of an operations control room: a small set of products whose price and stock many people watch at once. When an operator changes a price or sells a unit, the change is:

  1. committed once to Postgres (the durable truth, with an optimistic-concurrency version and an atomic oversell guard), then
  2. appended to a Redis Stream as an ordered, durable event with a stable id (<ms>-<seq>), then
  3. fanned out over Server-Sent Events (SSE) to every connected dashboard within ~100 ms.

Each client persists the last Stream id it applied, so on reconnect it replays exactly the events it missed via XREAD from that id — no gaps, no full refetch — and if its id was trimmed away it falls back to a fresh snapshot.

Definition of done (what the learner ends with, runnable for $0):

  • docker compose up -d brings up postgres:16 + redis:7; one psql load creates the products table and three seed rows.
  • One server binary/process (PORT=8080) serves the bundled dashboard at /, a JSON snapshot at GET /products, two write routes, and an SSE feed at GET /stream.
  • The learner opens http://localhost:8080/ in two browser windows: dragging the price slider in window A makes window B’s number tick within ~100 ms; clicking Sell 1 drops stock in both; selling the last unit flips both to SOLD OUT and the loser gets an out of stock toast.
  • The offline demo: take window B offline in DevTools, make changes in window A, bring B back online — the status pill reads Reconnecting…Replaying N events, the backlog fast-forwards, and B ends identical to A.
  • A second server instance on PORT=8081 against the same Redis proves N instances act as one: a write on A appears on a dashboard connected to B.
  • An integration test proves both guarantees: live delivery within ~100 ms and exact reconnect-replay.

Everything runs locally with free, open-source images and free tools. No account, quota, or card.


2. Architecture (prose diagram)

            ┌────────────── operator (browser) ──────────────┐
            │  public/index.html (fetch + EventSource)        │
            └───┬───────────────────────────┬─────────────────┘
        writes (PUT/POST)            live feed (GET /stream, SSE)
                │                            ▲
                ▼                            │ id:/data: frames + heartbeats
        ┌───────────────────────────────────────────────┐
        │  Ticker server  (Go default | Hono/Bun)        │
        │  • GET /            -> serves public/index.html │
        │  • GET /products    -> snapshot (Postgres)      │
        │  • PUT /products/{id}/price  -> write path      │
        │  • POST /products/{id}/sell  -> write path      │
        │  • GET /stream      -> tail Stream, SSE fan-out │
        └───────┬───────────────────────────┬────────────┘
   commit (durable truth)            XADD / XREAD (ordered change feed)
                ▼                            ▼
        ┌───────────────┐            ┌────────────────────────┐
        │  Postgres 16  │            │  Redis 7  Stream        │
        │  products row │            │  key: warehouse:1       │
        │  + version    │            │  XADD MAXLEN ~ 10000    │
        └───────────────┘            └────────────────────────┘
  • Write path (the spine). A write UPDATEs the row in Postgres (bumping version, returning the post-write state), then — only on a committed row — XADDs a self-contained change event to the Stream. Commit before publish so the Stream never advertises a change that did not happen.
  • Fan-out. GET /stream is a long-lived text/event-stream response. The handler tails the Stream with a bounded blocking XREAD in a loop and writes one SSE frame per event. Because every instance tails the same Stream with plain XREAD (no consumer group), all instances see every event — the Stream, not the process, is the fan-out point, so horizontal scaling is free.
  • Reconnect-replay. The SSE id: field carries the Stream id. The browser’s EventSource resends it as Last-Event-ID on auto-reconnect; the server starts XREAD just after that id, so the first batch is the missed events. If the id is older than the Stream’s first retained entry, the server sends a resync event and the client reloads the snapshot.
  • The single invariant (state once, reference everywhere): Snapshot = baseline truth (Postgres), Stream = recent deltas (Redis); apply both through one idempotent reducer keyed by (id, version).

Fixed warehouse for this build

The Stream is keyed per warehouse as a design (warehouse:{id} keeps each feed small and lets a dashboard subscribe to just the warehouse it watches). This build commits to a single warehouse, warehouse:1, as the fixed key everywhere — the write routes XADD to it, GET /stream tails it, and the dashboard reads it. (Extending to a request-supplied warehouse — GET /stream?warehouse=N, write routes keyed by the product’s warehouse — is left as an exercise; if you do it, every step must use the same id.) The course must not teach a dynamic key it does not wire.


3. Runnable structure (the repo the learner ends with)

ticker/
├── docker-compose.yml          # postgres:16 + redis:7
├── db/
│   └── schema.sql              # products table + CHECK constraints + 3 seed rows
├── public/
│   └── index.html              # the zero-build dashboard (served at /)

├── (Go default)
│   ├── go.mod                  # module github.com/you/ticker
│   ├── cmd/server/main.go      # ENTRYPOINT: composes everything (below)
│   └── internal/...            # optional: split store/handlers if desired

└── (TypeScript alternative)
    ├── package.json            # bun; deps: hono, pg, redis@^5
    └── src/server.ts           # ENTRYPOINT: the Hono app + Bun export

The app entrypoint (composition root)

Both backends have one entrypoint that composes the whole app — it must, in order:

  1. Read config from env: DATABASE_URL, REDIS_URL, PORT (default 8080).
  2. Open the Postgres pool and the Redis client once at startup; close them on shutdown.
  3. Register all routes on one mux/app: GET /, GET /products, PUT /products/{id}/price, POST /products/{id}/sell, GET /stream.
  4. Serve static public/index.html at /.
  5. Listen on :${PORT}.

Go — cmd/server/main.go: pgxpool.Newredis.NewClient(redis.ParseURL(...))http.NewServeMux with Go 1.22 method patterns → port := os.Getenv("PORT"); if port == "" { port = "8080" }http.ListenAndServe(":"+port, mux). http.FileServer(http.Dir("public")) serves index.html at / automatically.

TypeScript — src/server.ts: new Pool({ connectionString })createClient({ url }); await redis.connect()new Hono() with routes → app.get("*", serveStatic({ path: "./public/index.html" })) fallback for the SPA root → export default { port: Number(process.env.PORT) || 8080, fetch: app.fetch }.

Key interfaces / contracts (named explicitly)

Both backends implement the same logical operations. Names are illustrative; the behaviour is the contract.

  • Store (durable truth, Postgres):
    • listProducts(ctx) -> []ProductSELECT ... ORDER BY id.
    • setPrice(ctx, id, newPrice, expectedVersion) -> (Product, conflict bool, found bool) — conditional UPDATE ... WHERE id=$id AND version=$expected RETURNING price, stock, version. On a one-row result: found=true, conflict=false, return the post-write row. On zero rows, disambiguate by re-SELECTing the row by id alone: if the row exists the version did not match ⇒ found=true, conflict=true and the returned Product is the current row (so the caller can 409 with live values); if the re-SELECT finds nothing the id is unknown ⇒ found=false (so the caller can 404). A conditional UPDATE cannot by itself tell “stale version” from “no such id” — the re-SELECT is what makes the 404 in §5 reachable.
    • sell(ctx, id, qty) -> (Product, outOfStock bool, found bool) — atomic UPDATE ... SET stock = stock - $qty ... WHERE id=$id AND stock >= $qty RETURNING price, stock, version. One row ⇒ found=true, outOfStock=false. Zero rows re-SELECTs by id: row present ⇒ found=true, outOfStock=true with the current row (409 out_of_stock); row absent ⇒ found=false (404). Same disambiguation as setPrice: the WHERE clause merges “insufficient stock” and “no such id”, and the re-SELECT separates them.
  • Feed (change feed, Redis):
    • publish(ctx, ev ChangeEvent) -> streamIDXADD warehouse:1 MAXLEN ~ 10000 * <fields>.
    • tail(ctx, fromID, block) -> ([]Entry, timedOut bool) — bounded XREAD BLOCK <ms> COUNT N STREAMS warehouse:1 <fromID>; on timeout returns timedOut=true (no entries) so the handler can heartbeat and re-loop.
    • firstEntryID(ctx) -> streamIDXINFO STREAM warehouse:1first-entry id (for the trimmed-id check).
  • Handler: parses/validates the request, calls Store then Feed, maps results to the HTTP contract in §5.

4. Data model

Table products (Postgres)

columntypeconstraintsnotes
idBIGINTGENERATED ALWAYS AS IDENTITY PRIMARY KEYproduct id (also the PK index)
nameTEXTNOT NULL
priceBIGINTNOT NULL CHECK (price >= 0)cents, never float
stockINTEGERNOT NULL CHECK (stock >= 0)the oversell guard floor
versionINTEGERNOT NULL DEFAULT 0optimistic concurrency + idempotency
updated_atTIMESTAMPTZNOT NULL DEFAULT now()wall-clock for the UI

The PK index on id is the only index the path needs (all reads/writes are by id or full-table snapshot of 3 rows). No FK in the base build — products is self-contained.

Prerequisite/seed rows (required before GET /products is expected to return 3):

INSERT INTO products (name, price, stock) VALUES
  ('Aurora Mug', 1499, 50),
  ('Aurora Tee', 2999, 12),
  ('Sticker Pack', 499, 200);

Loading the schema (the step that must exist before the snapshot is queried): the compose file mounts no init script, so load it explicitly after docker compose up:

psql "$DATABASE_URL" -f db/schema.sql
# or, without a local psql client, through the db container:
docker compose exec -T db psql -U postgres -d ticker < db/schema.sql

DATABASE_URL uses user postgres (the image’s default superuser, since compose sets only POSTGRES_PASSWORD/POSTGRES_DB). If you set POSTGRES_USER, change the URL to match.

Redis Stream warehouse:1

  • Created lazily by the first XADD; capped with MAXLEN ~ 10000 (approximate trim — a little over is fine and much cheaper than exact). No consumer group in the base build (see §8).
  • Stream field values are strings. Numbers are encoded as decimal text on XADD and parsed on read.

Migrations

db/schema.sql is the single migration for the base build. The course frames it as the v1 migration; any later table (e.g. a feature’s price_history) is an additive migration applied the same way.


5. API & event contract (canonical — every step, client, and test shares this)

All bodies are JSON. Money is integer cents. The product object is the single shared shape:

// Product  (snapshot rows AND the body of 200/409 write responses)
{ "id": 1, "name": "Aurora Mug", "price": 1499, "stock": 50, "version": 3 }

JSON type note (one canonical rule). GET /products and the write-route bodies return price, stock, version as JSON numbers. SSE stream frames carry the same fields but as strings (Redis Stream values are strings; the server forwards msg.Values/msg.message verbatim). Every client MUST coerce with Number(...) (web) / .toLong()/.toInt() (Kotlin). This split is deliberate and documented; do not “fix” one side.

Endpoints

MethodPathRequest bodySuccessError
GET/200 text/html (the dashboard)
GET/products200 JSON array of Product (ordered by id)
PUT/products/{id}/price{ "price": <int cents>, "version": <int> }200 JSON Product (post-write)409 JSON Product (current row) on version conflict; 400 on bad body; 404 if id unknown
POST/products/{id}/sell{ "qty": <int ≥ 1> }200 JSON Product (post-sale)409 { "error": "out_of_stock", ...currentRow } when stock < qty; 400 on bad body; 404 if id unknown
GET/stream— (optional Last-Event-ID header)200 text/event-stream (frames below)

On a successful PUT/POST, and only after the Postgres commit, the server XADDs one change event and returns the post-write Product. A 409 (conflict or out-of-stock) and a 404 (unknown id) emit no event. The write path’s conditional UPDATE returns zero rows for both a stale/insufficient write and an unknown id; the store op re-SELECTs the row by id to tell them apart (row present ⇒ 409 + current row, row absent ⇒ 404), so the 404 in the table above is actually reachable from a §3-built backend.

SSE wire format (GET /stream)

  • Data frame (one per change event):

    id: 1718900000000-0
    data: {"product_id":"1","price":"1399","stock":"50","version":"4"}
    

    data is the JSON-encoded Stream fields (strings — see the type note). The id: line is the Stream id.

  • Replaying frame (sent once at the start of a reconnect, only when ≥1 event is being replayed):

    event: replaying
    data: 5
    

    data is the count of replayed events. Guarded by count > 0, so a clean reconnect never flashes “Replaying 0”.

  • Resync frame (the client’s Last-Event-ID is older than the first retained entry):

    event: resync
    data: 1
    

    Tells the client to re-GET /products, then continue live.

  • Heartbeat (comment line, on each bounded-block timeout with no new events):

    :keepalive
    

    A comment (: prefix) is ignored by EventSource but keeps idle proxies/load balancers from killing the connection. It is the production-honesty half of the bounded XREAD loop.

ChangeEvent (the Redis Stream entry)

XADD warehouse:1 MAXLEN ~ 10000 * product_id <id> price <cents> stock <n> version <v> — fields are the same four the data frame carries. The Stream id <ms>-<seq> is Redis-assigned, monotonic, and opaque.


6. Build order (dependency-ordered; each step’s prerequisites already exist)

Common (always shown) unless tagged:

  1. Stand up Postgres + Redis (docker-compose.yml, export DATABASE_URL/REDIS_URL). (common)
  2. Concept: pub/sub vs Streams — why a change feed + fan-out + replay, and why Streams not pub/sub. (common)
  3. Feel fan-out + replay in raw redis-cli — two terminals: XREAD BLOCK parks and wakes; reconnect from an explicit id replays a gap. (De-risks the later server steps.) (common)
  4. Schema (db/schema.sql: products + CHECKs + 3 seed rows). (common)
  5. Load the schema into Postgres (psql -f / docker compose exec). Now GET /products can return 3. (common)
  6. Model the change event (fields, warehouse:1 as the fixed key, string values). (common)
  7. Scaffold the server — pool + client + GET /products snapshot + serve public/index.html + read PORT. (backend: go | typescript)
  8. ★ The write pathsetPrice/sell store ops: commit to Postgres, then XADD. (common — SQL + language-agnostic order; the concept/SQL is identical, code lands in step 9.)
  9. Wire the write HTTP routesPUT /products/{id}/price and POST /products/{id}/sell: parse body, call the write path, return 200+row / 409+current-row / 404 on unknown id (re-SELECT disambiguates the zero-row case — see §3), XADD on success. (backend: go | typescript)
  10. Optimistic concurrency — the conditional UPDATE409 + current value (SQL, concept). (common)
  11. Oversell guard in one atomic lineWHERE stock >= qty; contrast Aurora. (common)
  12. Client offset protocol — every SSE frame carries its Stream id; the id is the whole protocol. (common)
  13. ★ Tail the Stream + SSE fan-out — bounded XREAD BLOCK loop + heartbeat on timeout; id:/data: frames; default start $. (backend: go (id=fanout-go) | typescript (id=fanout-ts))
  14. Reconnect-and-replayLast-Event-IDXREAD from that id; replaying N (guarded >0), trimmed-id → resync. (backend: go | typescript)
  15. Idempotent apply by (id, version) — the client reducer; the single invariant box. (common)
  16. Build the dashboard (public/index.html: snapshot + EventSource + slider/Sell/Sold-Out). (frontend: web-client)
  17. Status pill + offline demo (Live/Reconnecting/Replaying; DevTools-offline reconnect). (frontend: web-client)
  18. Multi-instance fan-out — two instances (PORT=8080/8081), one Stream. (common)
  19. Bound the StreamXADD MAXLEN ~; snapshot-first recovery. (common)
  20. Consumer groups: the other half of Streams — name XREADGROUP/XACK/XPENDING/XAUTOCLAIM, and why this fan-out deliberately uses plain XREAD (every instance sees every event), not a group. (common — advanced concept)
  21. Integration test: live + exact replay. (backend: go | typescript)
  22. Optional deploy — Cloud Run + self-hosted/managed Redis (blocking XREAD caveat). (common)
  23. Feature: AI anomaly note (feature: ai-anomaly) — see §8.
  24. Feature: native Compose client (feature: compose-client) — see §8.

Each backend-tagged step has both a go and a typescript variant (parity). The write path (8) is common SQL/order; its HTTP wiring (9), fan-out (13), replay (14), and tests (21) fork by backend.


7. Backends — same contract, parity points

Go (default) and TypeScript (Hono/Bun) implement §5 identically. Parity checklist:

ConcernGoTypeScript
Configos.Getenv("PORT"), default 8080; redis.ParseURLNumber(process.env.PORT)||8080; createClient({url})
Static /http.FileServer(http.Dir("public")) (serves index.html)serveStatic({ path: "./public/index.html" }) fallback
DB drivergithub.com/jackc/pgx/v5 (pgxpool)pg (node-postgres Pool)
Redisgithub.com/redis/go-redis/v9redis (node-redis, pin redis@^5)
Write routesmux.HandleFunc("PUT /products/{id}/price", ...) (1.22 path values)app.put("/products/:id/price", ...)
Fan-out readXRead with Block: 15*time.Second; timeout ⇒ redis.Nil ⇒ heartbeat + re-loopxRead({key,id},{BLOCK:15000,COUNT:N}) on redis.duplicate(); timeout ⇒ null ⇒ heartbeat + re-loop
Blocking connthe request’s own goroutine/connection is finededicated redis.duplicate() per stream (never the shared client)
first-entryrdb.XInfoStream(ctx, "warehouse:1").Result().FirstEntry.IDinfo["first-entry"].id (node-redis v5 returns the hyphenated key, not firstEntry)

Critical parity nuance (verified):

  • Bounded BLOCK, not BLOCK 0. go-redis Block: 0 blocks forever; node-redis xRead with BLOCK: 0 has a documented foot-gun where the promise can fail to resolve (redis/node-redis #2258). Using a bounded block (15s) in a re-loop on a dedicated connection is reliable on both clients, makes heartbeats natural, and keeps the two servers behaviourally identical. This is the canonical design — the course must not present BLOCK 0 as straightforwardly working on node-redis.
  • Replay = same loop from a different id on both; the first batch (when count > 0) is the missed set.
  • Both reset products + the Stream between integration-test runs and t.Skip/skip when env is unset.

8. Optional feature modules (off by default)

  • ai-anomalyGET /insights: read the last ~200 Stream events (XRANGE warehouse:1 - + COUNT 200), ask Gemini for structured JSON (responseMimeType: "application/json" + responseSchema) → { headline, restock[], price_anomalies[] }. Read-only: it surfaces advice for a human, never changes a price or stock. Keep GEMINI_API_KEY server-side; link the official structured-output docs rather than pinning a model id. Backend-agnostic (the prompt + schema are the lesson; wiring described in the AgentPrompt). $0: a free Google AI Studio key.
  • compose-client — a native Jetpack Compose client beside the web dashboard, against the unchanged backend (http://10.0.2.2:8080 from the emulator). Consumes the same /products + /stream; the lesson is that the realtime protocol is client-agnostic, and that — unlike the browser’s EventSource — OkHttp’s okhttp-sse EventSource does not auto-reconnect or auto-send Last-Event-ID, so Android owns reconnect-and-replay itself (idempotent apply by version). UI-only; backend unchanged. $0: emulator ships free with Android Studio.

Why the base build deliberately uses plain XREAD, not a consumer group: SSE fan-out wants every instance to see every event (a dashboard mirror), which is exactly plain XREAD. Work distribution (process each event once across a pool) is the consumer-group job: XGROUP CREATE, XREADGROUP >, XACK, the Pending Entries List (XPENDING), and XAUTOCLAIM to reclaim stuck messages. The course names these and explains the judgement (fan-out-read vs group-read) so the choice of plain XREAD here is load-bearing, not accidental. (A consumer-group-worker feature is a natural future add-on; not in scope here.)


9. Free-to-complete ($0)

PieceFree setup
Postgrespostgres:16 via Docker Compose (open source)
Redisredis:7 via Docker Compose (open source); XADD/XREAD/Streams are core, not a paid tier
ServerGo toolchain or Bun — both free; one local process
Dashboardone static public/index.html, no build, no framework
Gemini (feature)free Google AI Studio key
Compose client (feature)Android Studio emulator — free, no device/account
Deploy (optional)not required to see it work; Cloud Run has a free monthly allotment; self-host redis:7 so blocking XREAD works (some serverless Redis free tiers lack blocking XREAD — fall back to short non-blocking polling there)

docker compose up + one psql load + run the server is the entire demo. Nothing in the project requires a paid service, and the platform never runs anything for the learner.