This project and post were created for the purposes of entering the H0 Hackathon (Hack the Zero Stack with Vercel v0 and AWS Databases). #H0Hackathon
Live commerce (Whatnot, TikTok live drops, real-time bidding wars) is one of the fastest-growing corners of online entertainment. It is also one of the meanest distributed-systems problems you can pick, because the moment it goes global you have thousands of people hammering the same row at the same instant, with money on the line and zero tolerance for error:
- Two bids must never both win.
- The price must only go up.
- The last bid before the clock hits zero must count.
Most demos hand-wave this and hope the race conditions stay rare. I wanted the opposite: a live-auction platform, GavelLive, where correctness is the headline feature, proven on screen with a load test instead of asserted in a README.
The stack is deliberately small: Next.js on Vercel for the UI and serverless API, and Amazon Aurora DSQL as the single source of truth. This post is how the two fit together, and why DSQL is what makes the guarantee provable.
Live app: https://gavellive.vercel.app
Code: https://github.com/amoghsingh130/gavellive
Why Aurora DSQL for an auction
An auction is a contention magnet: every bidder reads the same current_high_bid and races to beat it. The naive approaches all have failure modes:
- App-level locks don't survive across stateless serverless invocations.
-
SELECT ... FOR UPDATErow locks serialize everyone behind one row and fall apart across regions. - Read-then-write without isolation is a textbook lost-update bug: two bidders read $100, both bid $110, one write silently clobbers the other.
Aurora DSQL is built for exactly this. It gives you serializable isolation with optimistic concurrency control (OCC), with no row locks at all. Transactions proceed in parallel, and at COMMIT time DSQL aborts whichever transaction would have broken serializability, surfacing it as PostgreSQL error 40001. That turns "did we just lose a bid?" from a hope into a guarantee the database enforces. It is also Postgres-wire-compatible and serverless, which fits a Vercel deployment perfectly: there is no instance to size or babysit.
Connecting from Vercel: IAM tokens, not passwords
The first thing that is different from a normal Postgres app: Aurora DSQL has no static password. You authenticate with a short-lived IAM auth token (valid about 15 minutes) generated with the AWS SDK's DsqlSigner. Great for serverless (no secret to leak), but it means you must not hold a long-lived pool, because the token expires and the function is ephemeral anyway. The pattern is: generate token, open connection, query, close.
Here is the entire connection helper (src/lib/db.ts):
import { Client } from "pg";
import { DsqlSigner } from "@aws-sdk/dsql-signer";
const region = process.env.AWS_REGION ?? "us-east-1";
const host = process.env.DSQL_ENDPOINT; // <cluster-id>.dsql.<region>.on.aws
async function generateAuthToken(): Promise<string> {
const signer = new DsqlSigner({ hostname: host!, region });
// "admin" role uses the admin token; custom DB roles use the standard one.
return signer.getDbConnectAdminAuthToken();
}
export async function getClient(): Promise<Client> {
const token = await generateAuthToken();
const client = new Client({
host,
port: 5432,
user: "admin",
database: "postgres",
password: token, // the IAM token *is* the password
ssl: { rejectUnauthorized: true }, // DSQL requires TLS
});
await client.connect();
return client;
}
// Run work against a short-lived connection, always closing it after.
export async function withDb<T>(fn: (c: Client) => Promise<T>): Promise<T> {
const client = await getClient();
try {
return await fn(client);
} finally {
await client.end();
}
}
That is it. Credentials come from the default AWS provider chain (env vars on Vercel, or an attached role), so there are zero database passwords anywhere in the codebase.
The correctness core: one serializable transaction plus a retry loop
Every bid is one DSQL transaction: read the auction snapshot, validate, insert the bid, update the high bid, extend the clock if we are inside the anti-snipe window, then COMMIT. The magic is what happens when two of these race. DSQL aborts the loser with 40001, and we simply re-read and retry. Business rejections (bid too low, auction ended) are terminal and return immediately. The key is telling those two cases apart.
const MAX_RETRIES = 10;
// DSQL surfaces OCC/serialization aborts as 40001 (deadlock as 40P01).
function isConcurrencyConflict(err: unknown): boolean {
const code = (err as { code?: string })?.code;
return code === "40001" || code === "40P01";
}
function backoff(attempt: number): Promise<void> {
// Exponential backoff with full jitter, capped at 500ms.
const ceiling = Math.min(25 * 2 ** (attempt - 1), 500);
return new Promise((r) => setTimeout(r, Math.random() * ceiling));
}
export async function placeBid({ auctionId, bidderId, amount }) {
const client = await getClient();
try {
let attempts = 0;
while (attempts < MAX_RETRIES) {
attempts++;
try {
await client.query("BEGIN");
const { rows } = await client.query(
`SELECT current_high_bid, bid_increment, starting_price,
status, ends_at, anti_snipe_window_secs, anti_snipe_extend_secs
FROM auctions WHERE id = $1`,
[auctionId],
);
const a = rows[0];
// --- terminal business rejections: return immediately, no retry ---
if (a.status !== "live") {
await client.query("ROLLBACK");
return reject("auction_not_live");
}
if (Date.now() >= +new Date(a.ends_at)) {
await client.query("ROLLBACK");
return reject("auction_ended");
}
const minBid = a.current_high_bid == null
? Number(a.starting_price)
: Number(a.current_high_bid) + Number(a.bid_increment);
if (amount < minBid) {
await client.query("ROLLBACK");
return reject("bid_too_low");
}
// --- the write: bids table is append-only ---
await client.query(
`INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)`,
[auctionId, bidderId, amount.toFixed(2)],
);
await client.query(
`UPDATE auctions
SET current_high_bid = $1, current_high_bidder_id = $2
WHERE id = $3`,
[amount.toFixed(2), bidderId, auctionId],
);
await client.query("COMMIT"); // DSQL validates serializability here
return { status: "accepted", attempts };
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
if (isConcurrencyConflict(err) && attempts < MAX_RETRIES) {
await backoff(attempts); // a real conflict: re-read and try again
continue;
}
throw err;
}
}
} finally {
await client.end();
}
}
Two design decisions carry the whole thing:
-
The database, not the app, decides who wins. No locks, no queue, no Redis. DSQL's
COMMIT-time serializability check is the arbiter, so the guarantee holds no matter how many serverless functions fire at once or which region they run in. -
Separate retryable conflicts from terminal rejections. If you retry everything, you spin forever on a too-low bid. If you retry nothing, you fail legitimate bids that just lost a race. Branching on
40001is the difference between a system that works under load and one that does not.
Winner finalization uses the same philosophy: a lazy, close-on-read atomic UPDATE flips an auction to ended the instant its clock expires, so there is exactly one winner derived from the source of truth (no cron job that can double-fire).
Proven, not claimed
This is the part I care about most. GavelLive ships a load test that fires 300 concurrent bids at the live production endpoint, then verifies the invariants by querying DSQL directly:
| Invariant | Result |
|---|---|
| No lost / duplicate writes (bid rows == accepted responses) | ✅ |
| Final price == highest accepted bid | ✅ |
| Exactly one winner, and it's the top bidder | ✅ |
| OCC contention actually occurred | 414 retry attempts, max 4 on a single bid |
That last row is the proof the mechanism is real: 300 bids needed 414 total attempts, meaning DSQL genuinely aborted and retried hundreds of conflicting commits, and still landed on exactly one consistent winner with zero lost writes. The same proof runs in-app: open a lot, hit the Concurrency proof panel, and watch the transactions race and every invariant turn green live.
Things that surprised me
-
Aurora DSQL is a PostgreSQL subset. Notably no
FOREIGN KEYconstraints, so I enforce referential integrity in the app layer with UUID keys. A fair trade for the consistency and scale guarantees. - No connection-pooling mindset. Coming from RDS, the instinct is to pool. With DSQL plus ephemeral functions plus expiring tokens, short-lived connections are the correct pattern, not a compromise.
- OCC changes how you think. You stop reaching for locks and start designing transactions that are cheap to retry. Once it clicks, the code gets simpler, not more complex.
What's next
- Real-time push via DynamoDB Streams to Lambda fanout over API Gateway WebSockets, to replace polling for the live price ticker at high fanout.
- Multi-region active-active DSQL: bidders in two regions, one globally consistent price.
- Auth, seller dashboards, and payment capture.
Takeaway
If your app has a hot row everyone fights over (auctions, ticket drops, inventory, seat selection), pushing correctness down into a serializable database is the cleanest design I have found. Aurora DSQL made "exactly one winner, no lost bids, ever" a property the database enforces, and pairing it with Vercel serverless meant I shipped the whole thing with no infrastructure to manage and no static secrets. Best of all, I could prove the guarantee holds under real concurrent load, which, for anything touching money, is the only claim worth making.
Try it: https://gavellive.vercel.app
Code: https://github.com/amoghsingh130/gavellive







