Skip to content
Alexkutsenko95Public

About

Real-time auctions: NestJS, PostgreSQL, React. Bids stay correct under concurrency and reach every client live.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Bidstream

Real-time auctions where the price on your screen is the price in the database.

CI

Auction page: live price, bid history and the bid form

Bidstream is a full-stack TypeScript app: a NestJS API on PostgreSQL and a React client, kept in step over WebSockets. Sellers list an item with a starting price and a clock, bidders compete in real time, and the highest bid wins when the clock runs out.

It is small on purpose. The point is the part most demos skip: what happens when forty people bid in the same millisecond, when a phone loses signal halfway through a request, and when the server that accepted a bid is not the one your browser is connected to.

What is interesting here

Bids stay correct under concurrency

A bid is one conditional UPDATE (auctions.service.ts):

UPDATE auctions
SET current_price_cents = $amount, leading_bidder_id = $bidder, bid_count = bid_count + 1, …
WHERE id = $auction AND status = 'open' AND ends_at > now() AND seller_id <> $bidder
  AND $amount >= CASE WHEN bid_count = 0 THEN starting_price_cents
                      ELSE current_price_cents + min_increment_cents END
RETURNING *;

The row lock serialises competing bidders, and PostgreSQL re-checks the WHERE clause against the latest committed row before applying the update. There is no read-then-write window in application code to get wrong. If nothing matched, a second query works out which rule the bid broke and says so: too low (with the new minimum), auction closed, or your own auction.

concurrency.test.ts races 40 bidders against one auction. Same amount at once: exactly one is accepted. Forty different amounts in random order: the stored history is gap-free and strictly increasing, and the highest bid always wins.

A retry never bids twice

Every bid carries an Idempotency-Key. The client keeps one key per bid intent: it reuses the key after a network error or a 5xx (the server may or may not have recorded the bid), and drops it after a definite answer. The server returns the original bid for a repeated key, and a unique index on (bidder_id, idempotency_key) makes a duplicate physically impossible.

One test fires eight copies of the same request at the same time and expects one bid and seven replays. The first version of this code failed it: two copies waited on the auction's row lock, woke up to a price their own first copy had just raised, and were told they bid too low. Copies of a request now queue on a transaction-scoped advisory lock first and see the committed result.

Live updates across instances, without Redis

The bid transaction itself calls pg_notify. PostgreSQL delivers a notification only when its transaction commits, so a rolled-back bid is never announced. Every API instance holds one LISTEN connection and fans events out to its own sockets (auction-events.listener.ts): the database that commits the bid is the one that announces it.

realtime.test.ts boots two app instances on one database, like two pods behind a load balancer, and checks that a bid placed through one reaches a socket connected to the other.

Clients that survive bad networks

  • bidCount only grows, so it doubles as a version: late or duplicated events cannot overwrite newer state (live.ts).
  • A gap in the bid sequence (bid 4 arrives, bid 3 never did) triggers a refetch.
  • On reconnect the client rejoins its rooms and refetches; if the server's own database listener reconnects, it tells every client to resync.
  • Countdowns run on the server's clock, corrected for the visitor's clock skew.
  • The socket is read-only. Every change goes through HTTP, which validates and authorises it.

Smaller things, done properly

  • Anti-sniping in the same UPDATE: a bid in the last 30 seconds moves the end to now() + 30s, atomically with the bid.
  • Money is integer cents end to end; even parsing what a person types avoids floats.
  • One set of zod schemas in packages/shared validates both the React forms and the API, and types the DTOs and socket events.
  • Sessions live in an httpOnly, SameSite=Lax cookie. Passwords are hashed with scrypt. Login takes the same time whether or not the email exists. Sign-in and bidding are rate-limited.
  • Deadlines use database time, so API instances never disagree about when an auction ends. Invariants are CHECK constraints, not just application code.

Architecture

flowchart LR
    subgraph Browser
        UI[React + TanStack Query]
    end
    subgraph "API instance (any number)"
        HTTP[REST controllers] --> SVC[Auction service]
        GW[Socket.IO gateway]
        L[LISTEN connection] --> GW
        C[Auction closer]
    end
    DB[(PostgreSQL)]

    UI -- "bids, auth (HTTP)" --> HTTP
    GW -- "live updates (WebSocket)" --> UI
    SVC -- "conditional UPDATE + pg_notify, one transaction" --> DB
    C -- "close due auctions + pg_notify" --> DB
    DB -- "NOTIFY on commit" --> L
Loading

Stack

Layer Tools
API Node.js 24, NestJS 12, Socket.IO, Drizzle ORM, PostgreSQL, zod
Web React 19, TanStack Query, React Router, Socket.IO client, CSS Modules
Shared TypeScript contracts: zod schemas, DTOs, socket events, money helpers
Quality Vitest, Supertest, Testing Library, ESLint, Prettier, strict TypeScript
Delivery npm workspaces, Turborepo, Docker, nginx, GitHub Actions

Run it

With Docker

docker compose up --build
docker compose exec api node dist/db/seed.js   # optional demo data

Open http://localhost:8080. The seed prints the demo accounts and their password.

Without Docker

You need Node.js 22.12+ (24 recommended) and PostgreSQL 14+.

npm ci
cp apps/api/.env.example apps/api/.env   # set DATABASE_URL and JWT_SECRET
npm run build
npm run db:migrate
npm run db:seed                          # optional demo data
npm run dev

The web app runs on http://localhost:5173 and proxies /api and /socket.io to the API on port 3000, so the browser sees one origin, exactly as it does behind nginx in Docker.

Tests

DATABASE_URL=postgres://user:pass@localhost:5432/bidstream_test npm test

API tests run against a real PostgreSQL, because the behaviour worth testing (row locks, unique indexes, LISTEN/NOTIFY) lives in the database. The suite covers auth, bidding rules, idempotent retries, anti-sniping, closing, concurrency, cross-instance real-time delivery and rate limiting. Web tests cover the live-state merge logic, the countdown and the bid form, including the "lost connection, press Bid again" path. CI also starts the whole Docker stack and checks it through nginx.

Project layout

apps/
  api/        NestJS: auth, auctions, real-time bridge, migrations, tests
  web/        React client
packages/
  shared/     contracts used by both

Trade-offs and what I would do next

  • Closing runs on a one-second sweep. Bids check ends_at themselves, so a late sweep never lets a late bid in; it only delays the "ended" announcement. At larger scale, a delayed job per auction would replace the sweep.
  • LISTEN/NOTIFY is not durable. A listener that drops misses what happened meanwhile, which the resync broadcast covers. It suits a handful of instances; beyond that I would move fan-out to Redis Streams or NATS.
  • Sessions are stateless JWTs. Signing out removes the cookie, but a stolen token stays valid until it expires (12 hours by default). Server-side sessions or short-lived tokens with refresh rotation would close that.
  • Out of scope: payments, item photos, email verification, browser end-to-end tests. Playwright would be the next test layer.

License

MIT

About

Real-time auctions: NestJS, PostgreSQL, React. Bids stay correct under concurrency and reach every client live.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages