Back to work
Resume Let's talk
Case study · Personal project

CricLot — a real-time
auction engine

Host IPL-style private cricket auctions with team budgets, live bidding, and separate host / team / display consoles — every screen driven by one server-authoritative source of truth over WebSockets, with the concurrency rules covered by an end-to-end suite in CI and a one-command deploy path.

Role Solo — design & build
Type Real-time web app
Stack FastAPI · Next.js · WebSockets · Docker · CI

The problem

A live auction is only fun if everyone sees the same thing at the same instant.

In a private cricket auction, a host runs the room while each team bids for players against a fixed purse. The hard part isn't the UI — it's consistency. Multiple people bid on the same lot within milliseconds, a countdown timer is ticking, and three different kinds of screen (host controls, team bidding, big-screen display) all need to agree on the current lot, the highest bid, remaining purses and squad sizes.

Naive approaches break down fast: client-side timers drift, HTTP polling shows stale state, and letting clients compute anything invites double-bids, over-budget purchases, and squads that exceed their cap.

The approach

I made the server the single source of truth. Clients never decide anything that matters — they send intents ("place bid", "start lot") and render whatever state the server broadcasts back. One authoritative process owns the timer, the current lot, every purse and every squad.

Architecture

Host console controls the room Team consoles place bids Display screen big-screen view WebSocket hub connection registry · broadcast fan-out Auction engine state machine · source of truth Database bids · purses · squads intents validate state persist broadcast

Clients send intents; the engine decides; the hub fans state back to every screen.

Key decisions

The timer lives on the server

Clients only render the countdown they're told about. Because the authoritative timer resets on each accepted bid, last-second sniping works exactly as players expect and no screen can disagree about how much time is left.

Validate before you broadcast

A bid is checked against purse, increment and squad cap before it becomes state. Only accepted bids are persisted and fanned out, so an invalid bid never flickers onto other screens and can't leave the auction in a bad state.

One message shape for every screen

Host, team and display consoles subscribe to the same state stream and just render their slice of it. That kept the protocol small and made adding a new kind of screen a front-end-only change.

Roles resolved on the server, not the client

The seat endpoint resolves host, team or display from a JWT or an access key, so a read-only board can join a room without an identity while host-only controls return 403 for a team key. Authorization is a server concern; the front end just hides what it was never allowed to call.

Scaling out would break correctness — so it doesn't

The timer runtime and the WebSocket hub are in-process. A second worker would fork a live auction into two conflicting truths, so the deploy configs deliberately pin one instance and one worker. Horizontal scale here needs shared timer state and a broker-backed hub first — that's a real trade-off, documented rather than hidden behind an autoscaler.

Deterministic shutdown beats a passing-most-of-the-time suite

Dropping an auction runtime used to cancel its timer tasks without awaiting them, so a timer caught mid-query was still unwinding as the event loop closed — deadlocking the end-to-end suite about half the time during teardown. Awaiting cancellation, disposing the test engine while its loop is alive, and reading test WebSocket frames through a bounded queue means a missing broadcast now fails instead of hanging.

Delivery & testing

A real-time system is only trustworthy if the concurrency rules are verified rather than asserted, so the auction's hard edges are covered end to end:

Outcome

Measured with the bundled load script against a single instance — the topology the deploy configs actually pin.

~5 ms / 16 ms bid fan-out p50 / p95 across 50 sockets
3 roles authorized server-side from one state stream
0 refreshes needed — and 0 floats, money is integer Lakhs

Stack

FastAPINext.js 15WebSocketsSQLAlchemyTypeScriptJWTSQLite / PostgresDockerFly / RailwayVercelGitHub Actionspytest

Want the deeper walkthrough?

Read the architecture wiki Back to all work