Skip to content
Back to projects

High-concurrency numbered-inventory sales platform

Sales of finite, individually numbered inventory, opening at an announced time. The same number can never be sold twice, and the opening spike cannot take the database down.

Anonymized case study: client names, infrastructure data, and sensitive details have been omitted for confidentiality.

Context

A platform with an admin panel, an API, and a public storefront. Sales open at an announced date and time, which concentrates demand into short windows, with many people competing for the same numbers at the same moment.

Challenge

Two problems that make each other worse. The first is correctness: the same number can never be sold twice. The second is load: the opening is announced, and the naive solution, reserving the number inside the request, puts the database on everyone's critical path at the same instant.

My role

Platform architecture, backend engineering, data modeling, asynchronous pipelines, and infrastructure.

Responsibilities

  • Platform and data-model architecture.
  • Backend engineering, with bounded contexts per domain.
  • Asynchronous pipelines for reservation, payment, and expiry.
  • Payment gateway integration.
  • Infrastructure as code and the deployment pipeline.
  • Role-based authorization and rate limiting.

Constraints

  • Selling the same number twice is a correctness problem, not a performance one. There is no margin for error.
  • The spike arrives concentrated, at a time the business itself announced.
  • A product can hold millions of numbers, so storing each number as a row does not scale.
  • An abandoned reservation has to return to inventory on its own, with no manual intervention.
  • White-label platform: each client with their own domain and visual identity.

Architecture

  • An Elixir/Phoenix API: massive concurrency and fault isolation are the platform's native shape, which is exactly the shape of the problem.
  • Availability held as a binary bitmap, one bit per number, instead of one row per number. A million numbers fits in roughly 125 KB.
  • Available, reserved, and paid counters kept beside the bitmap, so answering how many are left never scans the table.
  • Reservation does not happen inside the request. The API writes the order, publishes a job to the queue, and responds immediately.
  • Workers drain the queue at a sustainable rate and perform the actual reservation inside a transaction.
  • A unique index over product and number: the database is what arbitrates who won a contested number.
  • A scheduled job returns expired reservations to inventory.
  • The public storefront is built as a static site per client, served from its own CDN distribution.

Main technical decisions

A binary bitmap instead of one row per number.

Why

A product can hold millions of numbers. Materializing each one as a row means millions of records that exist only to say a number is free, and every availability query becomes a scan. One bit per number answers the same question by reading a few KB, and the counters alongside it avoid counting the whole table on every hit.

Trade-offs

  • A number loses its own identity in the database: attaching metadata per number requires a separate table.
  • Updating the bitmap is read, modify, write, which makes the lock mandatory.
  • The counters must be updated under that same lock, or they start lying.

Reserve outside the request, with the queue absorbing the spike.

Why

Reserving inside the request puts the database on everyone's critical path at the same instant, which is precisely when sales open. With a queue, the API only has to write an order and publish a message, and the database sees a steady stream instead of an avalanche. The queue is the shock absorber, and it is allowed to grow.

Trade-offs

  • The purchase stops being instant: the user is told their numbers are being reserved, rather than receiving them on the spot.
  • Under a large spike the queue takes minutes to drain, and the interface has to own that rather than hide it.
  • More moving parts to operate and monitor: the queue, the workers, and their backlog.
  • In exchange, the system runs late instead of falling over.

The database's unique index is the guarantee. The pre-flight check is only an optimization.

Why

The availability query runs outside the transaction, so it is a fast filter and not a promise: between reading and writing, another order may have taken the number. What arbitrates the race is the unique index over product and number. Two concurrent orders for the same number collide on the index, the loser hits a uniqueness violation, and its whole transaction rolls back. The pessimistic lock does not guarantee uniqueness: it protects the derived state, the bitmap and the counters, from a lost update.

Trade-offs

  • The loser pays a full rollback instead of being reassigned to a number that was left over.
  • The bitmap row becomes a write hot spot, serializing reservations for that product.
  • This is only sustainable because the queue already regulated the arrival rate: the lock is never contended by raw user traffic, only by the workers.
  • The two decisions are a pair. Neither would work without the other.

A static storefront per client, instead of runtime multi-tenant rendering.

Why

Each white-label client gets its own static build, with theme and identifier baked in at build time, its own certificate, its own CDN distribution, and its own bucket prefix. Adding a client becomes a data change in the infrastructure rather than a code change.

Trade-offs

  • Any theme change requires a rebuild, a publish, and a CDN invalidation.
  • It gives up the framework's image optimization, which does not work in a static export behind a CDN.
  • In exchange, origin cost per storefront is close to zero and client isolation happens at the edge.

Implementation

  • Admin panel, API, and public storefront, each in its own repository.
  • Bounded contexts in the backend, separating orders, numbers, payments, and notifications.
  • Dedicated queues per job type, with concurrency tuned to the shape of each workload.
  • File uploads sent straight to object storage via a pre-signed URL, never through the API.
  • Payment gateway integration behind a single interface.
  • JWT authentication with distinct roles and, for the buyer, passwordless login via a code sent to their phone.

Production concerns

  • Infrastructure as code, with versioned modules and separate state per environment.
  • Promotion to production only from the staging branch, enforced by the pipeline itself.
  • A multi-stage Docker image running as an unprivileged user, with an immutable per-commit tag so rollback is possible.
  • Migrations run in their own container before the application comes up.
  • A dead-letter queue per pipeline, telemetry, and structured logs per job.
  • There is a scheduled job whose only purpose is to repair the gap between the database commit and the queue publish: if an order is marked paid but the following enqueue fails, its numbers stay stuck as reserved. Rather than pretending dual writes never fail, a reconciler finds those orders and fixes them.

Results

  • Selling the same number twice is impossible by construction, arbitrated by the database rather than by an after-the-fact check.
  • The API keeps responding through the opening, because the spike goes to the queue and not to the database.
  • Availability for millions of numbers fits in a structure of a few KB.
  • Abandoned reservations return to inventory without anyone having to act.
  • The dual-write failure, which is known and unavoidable, has an automatic repair instead of a support ticket.

Stack

  • Elixir
  • Phoenix
  • Ecto
  • PostgreSQL
  • Broadway
  • Amazon SQS
  • Next.js
  • TypeScript
  • Docker
  • Terraform
  • AWS