<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Sumon Molla Selim</title>
    <description>The latest articles on DEV Community by Muhammad Sumon Molla Selim (@sumonselim).</description>
    <link>https://dev.to/sumonselim</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3781756%2Fcf8ed9f7-e136-44a3-bc14-6be6039c0b78.jpg</url>
      <title>DEV Community: Muhammad Sumon Molla Selim</title>
      <link>https://dev.to/sumonselim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sumonselim"/>
    <language>en</language>
    <item>
      <title>Designing a Flash-Sale Seat Reservation System in AWS (Part 3): Holds, Payments, and the Slow Path</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:56:13 +0000</pubDate>
      <link>https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-3-holds-payments-and-the-slow-path-427a</link>
      <guid>https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-3-holds-payments-and-the-slow-path-427a</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; After admission, a seat is &lt;strong&gt;held for 10 minutes&lt;/strong&gt; while the candidate pays. The central rule for payments is one-sided: &lt;strong&gt;many paths can confirm a booking, but only one can release a seat.&lt;/strong&gt; Three paths can mark a booking &lt;code&gt;PAID&lt;/code&gt;: the browser callback, the gateway's signed webhook, and a background reconciler. &lt;/p&gt;

&lt;p&gt;The reconciler asks the gateway for the payment status. Each of the three paths checks with the gateway, and each is idempotent (safe to run more than once). Only the reconciler releases seats. It does so in two cases: the gateway reports that the payment failed or was cancelled, or the hold deadline has passed and the gateway has not reported the payment as complete. If the gateway cannot answer, the hold is extended. A seat is never released when the outcome is uncertain. The "cancel" or "failed" status in the redirect itself is ignored, because anyone can fake it. Durable writes happen outside the request path. &lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;SQS FIFO&lt;/strong&gt; queue feeds a worker that writes to &lt;strong&gt;DynamoDB&lt;/strong&gt; with conditional puts (writes that succeed only if a condition holds). The worker never touches the seat counters. &lt;strong&gt;DynamoDB TTL&lt;/strong&gt; and a &lt;strong&gt;Lambda driven by DynamoDB Streams&lt;/strong&gt; are only a safety net for expiry. On launch day, 31.6K holds were created for 20.7K seats, and the queue never held a message older than 4 seconds. The post ends with how we tested the design, in layers and many times over, and what that testing found before launch.&lt;/p&gt;

&lt;p&gt;This is the last part of a three-part series. &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-1-the-architecture-21lo"&gt;Part 1&lt;/a&gt; covers the architecture and the edge. &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-2-never-sell-a-seat-twice-4j61"&gt;Part 2&lt;/a&gt; covers how seat admission stays correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hold lifecycle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What states does a booking go through?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkq29q6czql0le9e7z8gx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkq29q6czql0le9e7z8gx.webp" alt="Figure 1: The hold lifecycle. A held seat ends in exactly one of two places: PAID, or RELEASED back into the pool." width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A booking has three durable (stored) states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HELD:&lt;/strong&gt; the seat is counted and a payment exists. The record carries a deadline 10 minutes in the future.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAID:&lt;/strong&gt; the gateway has confirmed the payment. The seat is permanent, and a confirmation email is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RELEASED:&lt;/strong&gt; the hold ended without payment. The seat is back in the pool, the record is deleted, and the candidate can try again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If payment creation or the enqueue fails, the booking never reaches &lt;code&gt;HELD&lt;/code&gt;. The API undoes the admission before it responds.&lt;/p&gt;

&lt;p&gt;The 10-minute hold is a trade-off in both directions. A shorter hold returns unpaid seats sooner, but it punishes anyone who needs some time to approve a payment on their phone. A longer hold keeps seats locked by people who have left and will not pay. Ten minutes was comfortably longer than the gateway's checkout took in testing, and it kept resale fast. On launch day, 31.6K holds were created for 20.7K seats. About a third of the holds expired or failed, and their seats were sold again within the hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 1: payment signals you cannot trust
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why can't the payment redirect decide the outcome?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After paying, the gateway redirects the browser to &lt;code&gt;/payment/callback?paymentID=...&amp;amp;status=success|failure|cancel&lt;/code&gt;. The obvious design is to trust that status: mark the booking paid on &lt;code&gt;success&lt;/code&gt;, and release it on &lt;code&gt;cancel&lt;/code&gt;. That design is broken in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An attacker controls the query string.&lt;/strong&gt; Anyone can type &lt;code&gt;status=cancel&lt;/code&gt; for a payment ID they have seen. That would free a seat that is still being paid for, or one that is already paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects can be old or repeated.&lt;/strong&gt; The browser's Back button, a second tab or a retried request can all deliver the same redirect again, sometimes after the booking has already changed state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A redirect may never arrive.&lt;/strong&gt; The user closes the tab, their phone loses signal, or the redirect times out. The payment completed, but nobody told us.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We considered three designs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trust the redirect status&lt;/td&gt;
&lt;td&gt;Easy to fake, easy to repeat, and easy to lose, as above.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirm every redirect with the gateway's query API before acting&lt;/td&gt;
&lt;td&gt;Better, but the gateway's query API only reports a definite status for a &lt;em&gt;completed&lt;/em&gt; payment. So a cancel cannot be confirmed one way or the other. Every fake redirect would also cost us a call to a rate-limited API.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Confirm-only redirects, one releaser (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A redirect can help to confirm a payment. It can never release a seat.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbvxs0wwytc9up6tgv89n.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbvxs0wwytc9up6tgv89n.webp" alt="Figure 2: Three independent paths can move a booking to PAID. Each one checks with the gateway and is idempotent. Only the reconciler releases seats (the TTL safety net reuses the same guarded rollback). A redirect that says cancel changes nothing." width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three confirm paths are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Browser callback.&lt;/strong&gt; The API accepts the callback only for a payment ID that it issued, and only with the per-payment signature that the gateway returned when the payment was created. It also refuses to charge once the hold deadline has passed. Otherwise we could take money for a seat that the reconciler has already released. It then calls the gateway's execute API under a lock with an owner token (&lt;code&gt;SET NX&lt;/code&gt; with a random token, and only the owner of that token can release the lock). So the payment is executed at most once, even if the redirect arrives twice. If the execute call times out, the API queries the payment once, as the gateway's own integration guide requires. It does that only to recover a completed payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway webhook.&lt;/strong&gt; The gateway also sends a signed notification (standard AWS SNS HTTP delivery) when a payment completes. The handler verifies the SNS signature and looks up the booking by invoice number. It still queries the gateway and compares the transaction ID before it marks the booking paid. It always answers &lt;code&gt;200&lt;/code&gt; to a correctly signed message, even when it leaves the decision to the reconciler. That way SNS never builds up a flood of redeliveries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciler query.&lt;/strong&gt; This path is covered in Challenge 2.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every confirm path ends in the same conditional update: &lt;code&gt;HELD → PAID&lt;/code&gt;, which succeeds only if the record is still &lt;code&gt;HELD&lt;/code&gt; with the same payment ID. Whichever path gets there first wins, and the others do nothing. Confirmation emails are sent after that update succeeds, never before.&lt;/p&gt;

&lt;p&gt;One race condition shaped the design early. &lt;strong&gt;A fast payer can finish before the worker has written the record.&lt;/strong&gt; The hold is written to DynamoDB asynchronously, so a user who pays within seconds can come back before any &lt;code&gt;HELD&lt;/code&gt; record exists. So at admission, the API also stores a copy of the booking record in Redis, keyed by payment ID, with a TTL slightly longer than the hold. If the callback finds no record in the database, it writes the &lt;code&gt;PAID&lt;/code&gt; record from that copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 2: exactly one path releases a seat
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Who decides that a hold has expired?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;reconciler&lt;/strong&gt; is a background loop inside the API fleet. A Redis lock with an owner token makes sure that exactly one instance runs it at a time. Every 20 seconds, that instance does a sweep:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkazd0ymih4sgt0ks6hf.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkazd0ymih4sgt0ks6hf.webp" alt="Figure 3: One sweep. The gateway's answer decides the action. A query with no answer extends the hold instead of releasing it." width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clean up stale pending admissions&lt;/strong&gt; from Part 2: seats whose request died before it reached the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find &lt;code&gt;HELD&lt;/code&gt; records&lt;/strong&gt; within 6 minutes of their deadline, or past it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle the most overdue records first,&lt;/strong&gt; 8 at a time, within a 45-second time budget, so the sweep finishes before the next one starts. When many holds expire at the same time, the records that are closest to hurting a user go first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the gateway for the status&lt;/strong&gt; of each record:

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Completed:&lt;/em&gt; mark it &lt;code&gt;PAID&lt;/code&gt; and send the email. This recovers every user whose redirect never arrived.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Failed or cancelled, or not completed after the deadline:&lt;/em&gt; release the seat, using the payment-guarded undo from Part 2, and delete the record.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Still pending before the deadline:&lt;/em&gt; leave it alone. The user may still be paying.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;The query fails three times:&lt;/em&gt; extend the hold by 10 minutes, up to three times, then alert a human. &lt;strong&gt;We never release a seat only because we could not get an answer&lt;/strong&gt;, because the payment may have succeeded.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the only status query traffic we send to the gateway on the release side. Keeping it in one rate-limited loop keeps our use of the gateway's rate limits predictable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DynamoDB TTL is not the main timer.&lt;/strong&gt; TTL (time to live) is set on the record's deadline attribute. But AWS deletes expired items on a best-effort schedule, and the delay can be long. During a sellout, that delay would mean seats that nobody could buy. So the reconciler expires holds itself, on time. TTL, a DynamoDB Stream, and an &lt;strong&gt;expiry Lambda&lt;/strong&gt; form the safety net. The Lambda acts only on deletions made by the TTL service itself (the reconciler's own deletes are ignored), and only if the deleted record was still &lt;code&gt;HELD&lt;/code&gt;. It then runs the same payment-guarded undo. If the reconciler already released that seat, the Lambda's undo does nothing.&lt;/p&gt;

&lt;p&gt;One bug from QA is worth sharing. Paid records must &lt;strong&gt;remove&lt;/strong&gt; the TTL attribute. One of our paths wrote &lt;code&gt;PAID&lt;/code&gt; records with the hold's TTL still set. DynamoDB would have deleted those confirmed bookings 10 minutes later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 3: one token, a rate-limited gateway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you share an auth token across a fleet?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The gateway's API calls need a short-lived token. The token &lt;em&gt;grant&lt;/em&gt; endpoint (the endpoint that issues a new token) is rate-limited to a couple of calls per hour. If every instance fetched its own token, a deploy or a restart would use up the whole allowance in seconds and lock the entire fleet out of payments.&lt;/p&gt;

&lt;p&gt;So the token lives in Redis, shared by the whole fleet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One refresher at a time.&lt;/strong&gt; An instance that finds no valid token takes a &lt;code&gt;SET NX&lt;/code&gt; lock with a 45-second TTL. Only the lock holder calls the grant endpoint. All other instances wait for the new token to appear, while still respecting their own request deadline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh early.&lt;/strong&gt; The token is cached until a few minutes before it expires, so a request never uses a token at the moment it expires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The grant call outlives the request that started it.&lt;/strong&gt; The grant call runs on its own 30-second deadline, separate from the request that triggered it. A user's 8-second timeout cannot stop a grant halfway and force another one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait after a failure.&lt;/strong&gt; A failed grant keeps the lock for 60 seconds instead of releasing it. So the fleet does not retry in a loop and use up the hourly allowance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Challenge 4: durability off the hot path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How does the database keep up without slowing down bookings?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh77lu62qb7dolvhoaqw2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh77lu62qb7dolvhoaqw2.webp" alt="Figure 4: The slow path. SQS FIFO absorbs the burst. The worker writes to DynamoDB at its own pace. Streams feed the expiry safety net and the back-office sync." width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We considered four ways to store each booking durably.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why not (or why)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write to DynamoDB inside the request&lt;/td&gt;
&lt;td&gt;Adds a network write to every admission. A throttle or a slow partition at T+0 (the moment registration opens) becomes latency the user sees, or an admission that must be undone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kinesis Data Streams&lt;/td&gt;
&lt;td&gt;Built for ordered, high-volume streams, but shards need capacity planning, and you must build per-record retries and dead-letter handling yourself.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQS standard&lt;/td&gt;
&lt;td&gt;Scales without limit, but it can deliver messages twice and out of order. The worker would have to handle all of that complexity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQS FIFO, high-throughput mode (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Removes duplicates on send and keeps order within each group. With a large number of message groups it has plenty of throughput. The dead-letter queue is built in.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Message group = the first 4 hex characters of the candidate hash.&lt;/strong&gt; That gives 65,536 groups. The groups spread the load, and ordering within a group keeps one candidate's messages in sequence. High-throughput FIFO mode (&lt;code&gt;DeduplicationScope=messageGroup&lt;/code&gt;, &lt;code&gt;FifoThroughputLimit=perMessageGroupId&lt;/code&gt;) removes the old per-queue throughput limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication ID = the candidate hash plus the request timestamp.&lt;/strong&gt; A retried send is treated as the same message, but a real re-booking is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility timeout of 60 seconds and 5 receives&lt;/strong&gt; before a message moves to the dead-letter queue. That queue has an alarm that fires on even one message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The worker on each instance long-polls the queue (it keeps a receive request open and waits for messages instead of asking repeatedly). It writes each record with a &lt;strong&gt;conditional put&lt;/strong&gt; (the write succeeds only if the record does not exist yet). When the condition fails, the worker has to find out why, and a guess is not good enough:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwei4qt5yn1zu8a2bjxr6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwei4qt5yn1zu8a2bjxr6.webp" alt="Figure 5: A conflict is classified with a strongly consistent read. Only a stale record from a released attempt is replaced. Anything unclear stays in the queue." width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PAID&lt;/code&gt;:&lt;/strong&gt; the callback got there first. Skip the message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HELD&lt;/code&gt; with the same payment ID:&lt;/strong&gt; a repeated delivery of the same message. Delete the message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HELD&lt;/code&gt; with a different payment ID:&lt;/strong&gt; a stale record from an earlier attempt that was released, after which the candidate booked again. Replace it, on the condition that the old payment ID is still there. We found this case in QA, and it is why the payment ID is part of the check. Without it, the stale record hid the new payment, and the new booking could never be marked paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The read failed:&lt;/strong&gt; leave the message unacknowledged and let SQS deliver it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The worker never touches the seat counters.&lt;/strong&gt; Seats belong to Redis and the admission script. Records belong to the worker. When both could decrease a counter, a redelivered message could free a seat that someone still held. Giving each piece of state exactly one owner made that bug impossible.&lt;/p&gt;

&lt;p&gt;DynamoDB itself uses on-demand capacity with one table. The partition key is the candidate hash. A GSI (global secondary index) on payment ID serves the callback's lookup. Point-in-time recovery and deletion protection are both on. In the first five minutes the worker wrote about 37,000 write units' worth of records. The table absorbed that without a single throttle, because the queue had already smoothed out the spike.&lt;/p&gt;

&lt;p&gt;Two more stream consumers connect to the table, both with failure handling built in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sync Lambda&lt;/strong&gt; copies every &lt;code&gt;PAID&lt;/code&gt; record into a relational database for the back-office app, using upserts (insert or update in one statement).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both Lambdas&lt;/strong&gt; use &lt;code&gt;ReportBatchItemFailures&lt;/code&gt;, split a batch in half on error, limit the record age, and send anything they give up on to an SQS on-failure destination. Without this, a dropped stream batch would mean a lost release or a missing paid booking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb65e9i1fojrrb6asii8m.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb65e9i1fojrrb6asii8m.webp" alt="Figure 6: Holds enqueued per 5 minutes. 15.3K in the first five minutes. The queue's oldest message was never older than 4 seconds." width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The live status document
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do thousands of writers keep one JSON file correct?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The availability counts on the page come from &lt;code&gt;status.json&lt;/code&gt; in S3 (see Part 1). The API and the expiry Lambda both write it. Our first version read the file, changed one field and wrote it back. With several writers at the same time, two writes overlapped, and an old write reopened a level that had just filled.&lt;/p&gt;

&lt;p&gt;The rule that fixed it: &lt;strong&gt;every writer builds the whole document from Redis&lt;/strong&gt;, where the counters and the open/closed flags live, and overwrites the file. Writers that run at the same time then arrive at the same result instead of competing over a copy. Two latches (one-time flags) keep the writes cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 2-second &lt;code&gt;SET NX&lt;/code&gt; throttle turns a burst of admissions into a single S3 write.&lt;/li&gt;
&lt;li&gt;The level-full and closed transitions each publish exactly once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A release clears those latches, so a freed seat reopens the level on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test, find, fix, repeat
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Was this design right the first time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The design in this series is the result of about four months of testing, finding problems, fixing them and testing again. That loop is one of the main reasons launch day was quiet. Every change went through several layers of testing, and each layer answered a different question.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft8kj7kg289ln2cz8okko.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft8kj7kg289ln2cz8okko.webp" alt="Figure 7: The testing layers, from fast and cheap at the top to slow and close to real at the bottom. A problem found in any layer was fixed, covered by a new test where possible, and then every layer was run again." width="800" height="623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests&lt;/strong&gt; for every package: each branch of the admission script, the gateway client, the webhook signature check and the status document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An end-to-end suite on a local copy of the stack&lt;/strong&gt;, with a mock payment gateway that can be told to fail or cancel. It runs a booking all the way to &lt;code&gt;PAID&lt;/code&gt;. It also runs the failure cases: a gateway error, a duplicate booking, a new booking after a released hold, a forged callback, a callback that arrives twice, and a payment that completes before the worker has written the record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-rate load tests&lt;/strong&gt; on a stack the same size as production. Each run used 40,000 test candidates, sent 2,000 bookings per second for 25 seconds, and then sent a slower stream of late arrivals and retries. A run passed only if admissions stopped exactly at the cap, DynamoDB reported no throttling, and the counters matched the database afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The payment gateway's sandbox&lt;/strong&gt;, for real payment flows: pay, cancel, let a hold expire, and book again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviews and audits&lt;/strong&gt; of the design, the code and the infrastructure. They looked for signals an attacker could fake, rate limits we could use up, and missing permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch rehearsals.&lt;/strong&gt; We ran the same reset, load-candidates and open steps that we would use on the day, more than a hundred times, from the same pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each layer found problems that the others missed. A few examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load tests:&lt;/strong&gt; the Redis client's default connection pool was sized for normal traffic, not for a burst. We raised it to 300 connections per instance and ran the test again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox:&lt;/strong&gt; the counters drifted after a user cancelled and then booked again. The worker found the old record, treated the new booking as a duplicate, and decremented a counter. This is why the worker never touches the counters, and why it classifies every conflict with a consistent read (Challenge 4).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing the undo paths:&lt;/strong&gt; a rollback that ran twice pushed a level counter below zero. This is why every undo checks and acts in one atomic step (&lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-2-never-sell-a-seat-twice-4j61"&gt;Part 2&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewing paid records:&lt;/strong&gt; a paid record could still carry the hold's TTL, so DynamoDB would have deleted a paid booking about ten minutes later. A paid record now has no TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security review:&lt;/strong&gt; the cancel status in the redirect has no signature, so anyone can fake it. Checking each one with the gateway would also use up the gateway's token limit. This is why a redirect can never release a seat (Challenge 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A week before launch:&lt;/strong&gt; two concurrent writers could leave the status page showing a full level as open. The writers now run in a fixed order, and the page also checks the counts, so a full level can never look open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these problems is unusual. Most of them only show up under a specific timing, order of events or load, and the rest only show up when someone looks for them on purpose. The only reliable way to find them before users do is to test at every layer, many times, and to test again after every fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What would I tell someone building this next?&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide the one fact everyone competes for in one atomic step, and keep it off the database.&lt;/strong&gt; Everything else in the system can be eventually consistent if that one decision is exact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the request path as short as possible.&lt;/strong&gt; Admit, create the payment, enqueue, and respond. Every durable write that can happen after the response should happen after the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every piece of state exactly one owner.&lt;/strong&gt; Redis owns seats, the worker owns records, and the reconciler owns releases. Most of our pre-launch bugs came from two components writing the same state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every external signal as a hint.&lt;/strong&gt; Redirects, webhooks and query results can be faked, delayed, duplicated or missing. Confirm with the source, make every state change conditional, and let exactly one path take the action that cannot be undone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never release when you are not sure.&lt;/strong&gt; If you cannot tell whether someone paid, extend the hold and alert a human. Selling a paid seat twice is worse than holding an unpaid one a little longer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency must live in the same atomic step as the action.&lt;/strong&gt; A check followed by a separate action is a race, and under a burst every race is lost sooner or later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add capacity before a sudden jump in traffic, and load-test with a mock of every external dependency.&lt;/strong&gt; The mock gateway let us practise the full booking path at full rate as often as we wanted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan as much time for testing as for building.&lt;/strong&gt; Test in layers, from unit tests to full-rate load tests and launch rehearsals. When you find a problem, fix it, add a test for it, and run every layer again. Most of what makes this design correct was learned that way.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result was the launch day in &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-1-the-architecture-21lo"&gt;Part 1&lt;/a&gt;: 20,700 seats sold, 99.99% of requests served without a server error, and zero double bookings.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>systemdesign</category>
      <category>cloudfront</category>
      <category>lambda</category>
    </item>
    <item>
      <title>Designing a Flash-Sale Seat Reservation System in AWS (Part 2): Never Sell a Seat Twice</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:49:57 +0000</pubDate>
      <link>https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-2-never-sell-a-seat-twice-4j61</link>
      <guid>https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-2-never-sell-a-seat-twice-4j61</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Every booking goes through one Redis Lua script. The script checks, in order, whether the candidate is already admitted, whether their level is full, and whether any seat is left overall. Then it counts the seat and marks the candidate. All of this happens as one atomic step (nothing else can run in between). Lua won over &lt;code&gt;MULTI/WATCH&lt;/code&gt; (too many retries when many requests compete) and distributed locks (extra round trips while holding the lock). An in-memory counter can still be wrong in four ways. Each needed its own fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Double rollbacks:&lt;/strong&gt; the undo step has a guard, so running it twice does nothing the second time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lost replies:&lt;/strong&gt; every call carries a nonce (a random value that identifies one request), so a retried call gets its original slot back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crashed requests:&lt;/strong&gt; a list of pending admissions, plus a cleanup job, returns seats that never reached the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover:&lt;/strong&gt; a booking counts only after the Redis replica confirms it has the write (&lt;code&gt;WAIT 1 200&lt;/code&gt;). Otherwise the booking is undone and the client retries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is Part 2 of a three-part series. &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-1-the-architecture-21lo"&gt;Part 1&lt;/a&gt; explains why seat admission lives in Redis rather than a database. &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-3-holds-payments-and-the-slow-path-427a"&gt;Part 3&lt;/a&gt; covers what happens after a seat is admitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariants
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What exactly does "never sell a seat twice" mean?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before writing any code, we wrote down three invariants (rules that must always be true). Everything in this post exists to keep them true.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Caps hold.&lt;/strong&gt; The number of admitted seats per level never exceeds that level's cap, and the total never exceeds the global cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One live booking per candidate.&lt;/strong&gt; A candidate code is linked to at most one seat that is held or paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No leaks.&lt;/strong&gt; Every admitted seat becomes either paid or released in the end. No seat stays counted with nobody holding it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Invariants 1 and 2 are what "zero double bookings" means. Invariant 3 stops the event from ending with unsold seats that the system forgot about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What lives in Redis?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;booking:open&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;The admin's open/close switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candidates:valid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;Allowlist of eligible candidate codes, loaded before opening&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candidate:email:&amp;lt;code&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;The email on file for each code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adm:seen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;Candidate codes that currently hold a seat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adm:total&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Seats admitted overall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adm:level:&amp;lt;L&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Seats admitted per level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adm:pending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hash&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;code → nonce, level, time, slot&lt;/code&gt; for admissions not yet safely in the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hold:*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;strings with a TTL (they expire on their own)&lt;/td&gt;
&lt;td&gt;Per-payment stashes (temporary copies): the booking record, the payment URL, and the execute and rollback guards&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two configuration choices matter more than they seem to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxmemory-policy noeviction&lt;/code&gt;.&lt;/strong&gt; When memory runs low, Redis must refuse writes with an error. It must never silently delete &lt;code&gt;adm:seen&lt;/code&gt; to free space. Losing that set would allow duplicates. A write error, on the other hand, simply becomes a 503 that the client can retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalised identifiers.&lt;/strong&gt; Candidate codes have their surrounding spaces removed and are converted to uppercase before any lookup. Otherwise the counter would treat &lt;code&gt;ab123&lt;/code&gt; and &lt;code&gt;AB123&lt;/code&gt; as two different people. A SHA-256 hash of the normalised code is the database key. Its first characters become the queue message group and the payment's invoice number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gates, then the script
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why check some things outside the script?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lub6gopc3w135e6d5ou.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lub6gopc3w135e6d5ou.webp" alt="Figure 1: The admission flow. The gates are simple reads. The script is one atomic call. The replica acknowledgement decides whether the admission counts." width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The gates are simple read checks. They reject most invalid traffic before any write happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is booking open?&lt;/strong&gt; If not, &lt;code&gt;503 closed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the code on the allowlist?&lt;/strong&gt; If not, &lt;code&gt;400 invalid_code&lt;/code&gt;. The allowlist is loaded into Redis from the back-office database before opening. There is deliberately &lt;strong&gt;no fallback to the database&lt;/strong&gt; while the system runs. A code that is missing from the allowlist must not turn into a database query at 50,000 requests a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the email match the code?&lt;/strong&gt; If not, &lt;code&gt;400 email_mismatch&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A hidden honeypot field is checked before all of these. (A honeypot is a form field that real users never see, so only bots fill it in.) A form that fills it in gets a fake success response that looks real, with no admission and no log entry.&lt;/p&gt;

&lt;p&gt;The gates can live outside the script because their values change only when an operator changes them, before the event. The checks that compete with each other at the moment of booking are all inside the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- KEYS: adm:seen, adm:total, adm:pending&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV: code, max_total, level, level_cap, nonce, now_ms&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;level_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'adm:level:'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;

&lt;span class="c1"&gt;-- 1. Already admitted? A retry of the same request gets its slot back.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SISMEMBER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HGET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lvl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;string.match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'^([^|]*)|([^|]*)|([^|]*)|([^|]*)$'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;lvl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;                                  &lt;span class="c1"&gt;-- duplicate&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Level full?&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;                               &lt;span class="c1"&gt;-- level full&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Any seat left overall?&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;                                  &lt;span class="c1"&gt;-- closed&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Admit: count, mark, and record as pending.&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SADD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HSET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters. The duplicate check comes first, so a candidate who already has a seat is told so, even after their level fills up. The level check comes before the global check, so the user learns the most specific reason. Only the last branch writes anything. A rejection therefore costs a few reads on one thread.&lt;/p&gt;

&lt;p&gt;The API loads the script once with &lt;code&gt;SCRIPT LOAD&lt;/code&gt; and calls it with &lt;code&gt;EVALSHA&lt;/code&gt;. After a restart or failover, the new primary has an empty script cache and answers &lt;code&gt;NOSCRIPT&lt;/code&gt;. The client reloads the script and retries once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not the other atomic building blocks in Redis?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Problem under a flash sale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;INCR&lt;/code&gt;, then check, then &lt;code&gt;DECR&lt;/code&gt; if the counter went too far&lt;/td&gt;
&lt;td&gt;The counter briefly goes above the cap, and readers can see that. The duplicate check is a separate step, and another request can run between the steps, so two requests for the same candidate can both pass.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MULTI/WATCH&lt;/code&gt; (optimistic transactions)&lt;/td&gt;
&lt;td&gt;Every booking at the same level watches the same key. In the opening second, nearly all transactions abort and retry. That creates a storm of retries on the busiest key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distributed lock (for example Redlock) around a read, change, write sequence&lt;/td&gt;
&lt;td&gt;At least two extra round trips while holding the lock, and a lock service that is one more thing that can fail.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lua script (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs from start to finish on the Redis thread. There is no lock to hold across the network and no retry loop.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two more design details keep the rest of the system simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Level full" is published once.&lt;/strong&gt; When a level fills, a &lt;code&gt;SETNX&lt;/code&gt; latch (a key that only the first writer can set) makes exactly one API instance rewrite &lt;code&gt;status.json&lt;/code&gt;. Thousands of requests cannot compete to publish the same change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A duplicate can continue.&lt;/strong&gt; Suppose a candidate's booking is still &lt;code&gt;HELD&lt;/code&gt; and they submit again, perhaps because their first request timed out in the browser. The &lt;code&gt;409&lt;/code&gt; response then carries their existing payment URL. The URL is read from a Redis stash (never from the database), and the page redirects them straight to payment. Without this, a user whose first response was lost could not reach the seat they already hold.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure mode 1: undo that runs twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What happens when rollback runs twice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A seat must be given back when a booking fails: the payment could not be created, the enqueue failed, or the hold expired without payment. Several paths can trigger that release. Early in load testing, two of them sometimes ran for the same booking, and the counters &lt;strong&gt;went negative&lt;/strong&gt;. The undo step decreased the counters every time it ran, even though removing the candidate from the set only had an effect the first time.&lt;/p&gt;

&lt;p&gt;The fix was to make undo a script that only acts if the candidate is still admitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SREM'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;  &lt;span class="c1"&gt;-- already undone&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DECR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                                     &lt;span class="c1"&gt;-- total&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DECR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'adm:level:'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                     &lt;span class="c1"&gt;-- level&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HDEL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                            &lt;span class="c1"&gt;-- pending entry&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fixes repeats, but not a &lt;strong&gt;stale&lt;/strong&gt; (out of date) undo. Suppose a candidate's first payment is released and they book again. Then a late event for the &lt;em&gt;first&lt;/em&gt; payment arrives, such as a TTL expiry or a repeated callback. A guard that only asks "is the candidate admitted?" passes, because the candidate &lt;em&gt;is&lt;/em&gt; admitted again. The stale event then frees the new seat.&lt;/p&gt;

&lt;p&gt;So every release that a payment triggers goes through a second guard, keyed by payment ID. &lt;code&gt;SET hold:rollback:&amp;lt;paymentId&amp;gt; NX EX 86400&lt;/code&gt; and the undo run in the same script. A payment can release a seat at most once, and only the payment that owns the seat can release it.&lt;/p&gt;

&lt;p&gt;The rule we took from this: &lt;strong&gt;any operation that more than one path can trigger must be idempotent, and the guard that makes it idempotent must live in the same atomic step as the action itself.&lt;/strong&gt; (Idempotent means it is safe to run more than once, with the same result.) A check followed by a separate action is a race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 2: the reply that never arrives
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What if the script succeeds but the API never hears about it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Networks drop replies. When that happens, the Redis client does the sensible thing and retries the command. For the admission script, that sensible retry is a disaster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa1txxhefvdnon4yeudgx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa1txxhefvdnon4yeudgx.webp" alt="Figure 2: Without a nonce, the retried admission finds the candidate already admitted and reports a duplicate. The user gets no payment link and the seat stays taken. With a nonce, the retry is recognised and returns the original slot." width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first call admitted the candidate. The retry finds them in &lt;code&gt;adm:seen&lt;/code&gt; and returns "duplicate". The user sees "already registered" with no payment link, and their seat stays counted, with no hold behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a nonce for each request.&lt;/strong&gt; The API generates a random nonce for each booking request and passes it to the script. On admission, the script records it in the &lt;code&gt;adm:pending&lt;/code&gt; hash (step 4 above). When a call finds the candidate already admitted, it compares nonces. The same nonce means this is the same request, retried, so the script returns the original slot. A different nonce means a real second attempt, so it returns duplicate.&lt;/p&gt;

&lt;p&gt;We also run the admission with a context that is separate from the HTTP request's cancellation. If a user closes the tab during the request, that cannot stop the call halfway through its retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 3: the request that dies halfway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What if the API crashes between admitting and enqueueing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The hot path is three steps: admit, create payment, enqueue. If the process dies after the first step, the seat is counted, but no record exists anywhere that could expire it. Neither the database nor the queue knows the seat exists. That would break invariant 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pending ledger closes the gap.&lt;/strong&gt; (The ledger here is simply the list of admissions that are not yet safe in the database.) Every admission writes an &lt;code&gt;adm:pending&lt;/code&gt; entry in the same atomic step. &lt;code&gt;/book&lt;/code&gt; clears the entry only after the enqueue has succeeded, because from that point on the queued record carries the booking. Any rollback clears the entry atomically too.&lt;/p&gt;

&lt;p&gt;On every sweep, the reconciler (the single background runner described in Part 3) looks for pending entries older than 15 minutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a database record exists for that candidate, the booking completed. The reaper (the cleanup step) removes the entry.&lt;/li&gt;
&lt;li&gt;If no record exists, the request died between steps. The reaper undoes the admission, with the same nonce check, so it can never undo a newer attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fifteen minutes is longer than the 10-minute hold plus the queue's worst-case delay. A slow but healthy request is never cleaned up by mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 4: the failover that forgets
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can a Redis failover sell a seat twice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, if you let it. ElastiCache replication is asynchronous. The primary confirms a write to the client before the replica has received it. If the primary dies in that gap, the replica is promoted to primary &lt;em&gt;without&lt;/em&gt; the last few admissions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficzu1lduflhodwk7dmn8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficzu1lduflhodwk7dmn8.webp" alt="Figure 3: Left: the primary confirms the last seat, then dies before it replicates the write, and the promoted replica sells the same seat again. Right: the API waits for one replica to confirm the write before it confirms the booking, so the promoted replica already has the admission." width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the last seat, that gap means one seat sold twice. The same applies to a candidate: the promoted replica does not know they booked, so they can book a second seat.&lt;/p&gt;

&lt;p&gt;We compared three options.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accept the risk&lt;/td&gt;
&lt;td&gt;The window is milliseconds long and failover is rare. But it breaks the one promise the client cared about, at exactly the moment when load is highest.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Count seats in the database too&lt;/td&gt;
&lt;td&gt;Correct, but it puts the database back on the hot path, which is what Part 1 worked to avoid.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wait for a replica acknowledgement (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Send the script and &lt;code&gt;WAIT 1 200&lt;/code&gt; together in one pipeline: wait up to 200 ms for one replica to confirm it has the write. If the replica confirms, the admission counts. If not, undo it and return a &lt;code&gt;503&lt;/code&gt; that the client can retry.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under normal conditions, replication lag between Availability Zones is a few milliseconds, so the wait adds almost nothing to a normal booking. Both commands go out in one pipeline, so there is no extra round trip.&lt;/p&gt;

&lt;p&gt;This choice has two consequences we accepted deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bookings fail closed.&lt;/strong&gt; That means: if the replica is down, every booking returns &lt;code&gt;503&lt;/code&gt; until ElastiCache replaces it. We chose correctness over availability for this one path. The page retries with increasing delays, and replacing a replica takes minutes, not hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WAIT&lt;/code&gt; is not consensus.&lt;/strong&gt; It makes a lost admission far less likely. It does not make Redis strongly consistent. With one replica, the node that confirmed the write is the node that gets promoted. That covers the realistic failure: the loss of one node or one Availability Zone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a final safety net, every five minutes the reconciler compares the Redis counters with a count of the records in DynamoDB. It raises an alert only if the mismatch is still there on two checks in a row, because records normally lag behind the counter by a few seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we tested it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you convince yourself a counter is right?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests against an in-memory Redis&lt;/strong&gt; for every branch of the script: caps, duplicates, nonce replay, idempotent and stale undo, and the pending reaper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Race tests&lt;/strong&gt; that send thousands of admissions at the same time against a small cap. They check that the admitted count equals the cap exactly and that no candidate appears twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real primary and replica in Docker&lt;/strong&gt; for the replica acknowledgement path. In-memory fakes usually report zero replicas, so they cannot test it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-rate load tests&lt;/strong&gt; with 40,000 test candidates against the real stack. They pass only if admissions stop exactly at the cap and the counters match the database afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On launch day the invariants held. 20,700 seats were sold, with zero double bookings and zero overbookings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;An admitted seat is only half a booking. &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-3-holds-payments-and-the-slow-path-427a"&gt;Part 3&lt;/a&gt; follows it through payment. It covers why a payment redirect can confirm a booking but never release one, and how a single runner makes every release decision. It also covers how a rate-limited gateway token is shared across the fleet, and how the queue and DynamoDB keep up without ever slowing down a booking.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>backend</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Designing a Flash-Sale Seat Reservation System in AWS (Part 1): The Architecture</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:46:21 +0000</pubDate>
      <link>https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-1-the-architecture-21lo</link>
      <guid>https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-1-the-architecture-21lo</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A national certification exam had 20,700 seats across five levels. When registration opened, far more people tried to book than there were seats. The previous system crashed under that load. It also sold the same seat twice. The new design follows three rules. &lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;one atomic Redis script decides who gets a seat&lt;/strong&gt;. (Atomic means the whole check-and-count runs as one step that nothing can interrupt.) No database takes part in that decision. &lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the request path does only three things&lt;/strong&gt;: admit the booking in Redis, create a payment, and put a record on a queue. Everything else happens later, in the background. &lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;everything that can be a static file is served from the edge&lt;/strong&gt; (the CDN, close to the user). CloudFront, S3 and an AWS WAF web ACL handled about three out of four requests without touching a server. &lt;/p&gt;

&lt;p&gt;We sized the API fleet from load tests and added servers before the event. We did not use autoscaling. In the first hour it served 1.5M+ requests at the origin, peaked at 54.8K requests per minute, kept median latency under 10 ms, returned a server error on fewer than 0.01% of requests, and sold every seat with zero double bookings.&lt;/p&gt;

&lt;p&gt;This is Part 1 of a three-part series. It is written as a design reference: the challenges, the options we compared, and why we chose what we chose.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 (this post):&lt;/strong&gt; the problem, the architecture, the edge, and capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-2-never-sell-a-seat-twice-4j61"&gt;never selling a seat twice&lt;/a&gt;, covering the admission script, retries, and failover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-3-holds-payments-and-the-slow-path-427a"&gt;holds, payments, and the slow path&lt;/a&gt;, covering the hold lifecycle, payment signals we cannot trust, and durable storage that runs in the background.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What made this hard?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The work itself looks simple: a form with a candidate code, an email address and an exam level, then a payment. The traffic pattern is what makes it hard. Registration opens at an announced minute, so almost everyone arrives in the same few seconds. They keep refreshing the page until they get a seat or the seats run out. The previous system crashed at exactly that moment. It also sold some seats twice. The client's requirement was simple and direct: &lt;strong&gt;stay up, and never book a seat twice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Written out as requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hard caps&lt;/td&gt;
&lt;td&gt;A cap (a maximum) per level, for five levels, and a global cap of 20,700. Never exceeded, not even by one seat.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One booking per candidate&lt;/td&gt;
&lt;td&gt;A candidate code can hold at most one live booking.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eligible candidates only&lt;/td&gt;
&lt;td&gt;The code must be on an allowlist loaded in advance, and the email must match the one we have stored for that code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pay to keep the seat&lt;/td&gt;
&lt;td&gt;A seat is held for 10 minutes while the candidate pays through the payment gateway. If the payment is not completed, the seat returns to the pool of free seats.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Money-safe&lt;/td&gt;
&lt;td&gt;Nobody is charged for a seat they did not get, and nobody who paid loses their seat.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Available under the burst&lt;/td&gt;
&lt;td&gt;The page must load and answer quickly, even when everyone arrives at once.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First come, first served&lt;/td&gt;
&lt;td&gt;No lottery. Whoever is admitted first gets the seat.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Never exceed the cap" and "one booking per candidate" are the two rules that define correctness. Part 2 is entirely about them. The rest of this post is about surviving the burst while keeping those rules true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the load
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does a flash sale look like in the traffic numbers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Figure 1 shows the real traffic around opening time, grouped into five-minute buckets from CloudWatch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpfcmbvg1ve9itmmjyda.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpfcmbvg1ve9itmmjyda.webp" alt="Figure 1: Requests at the edge compared with requests that reached the origin, in 5-minute buckets. The origin peaked at about 261K requests per five minutes. Over the first hour, the edge carried about four times as many requests as the origin." width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things stand out, and each one shaped the design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The ramp is a cliff.&lt;/strong&gt; In other words, traffic does not grow slowly; it jumps almost straight up. Origin traffic went from near zero to about 12K requests per minute in the first five minutes after opening, and to more than 50K per minute within twenty minutes. Anything that reacts to load, such as an Auto Scaling policy, a function with a cold start, or a database that scales its capacity, reacts too late. The moment that matters has already passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most requests are not bookings.&lt;/strong&gt; People reload the page and check availability long before and long after they submit the form. In the first hour CloudFront saw about 6.2M requests. Only about 1.5M of them reached the servers. Every request the edge answers is one that the booking path never has to handle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most of the origin traffic is rejections.&lt;/strong&gt; Once seats run low, most &lt;code&gt;/book&lt;/code&gt; calls end with a fast "no": duplicate, level full, or closed. A rejection must cost almost nothing, because there are far more rejections than admissions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Challenge 1: where do we decide who gets a seat?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the single hardest decision in this system?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every booking asks the same question: &lt;em&gt;is there still a seat at this level, and has this person already booked one?&lt;/em&gt; Thousands of requests ask it at the same instant. The answer must be exactly right for every one of them. Whatever component answers that question is the bottleneck of the whole system, so we chose it first.&lt;/p&gt;

&lt;p&gt;We considered four options.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Why not (or why)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A. Relational database row lock&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on a &lt;code&gt;seats_left&lt;/code&gt; row, then insert the booking&lt;/td&gt;
&lt;td&gt;Correct, but every booking must wait its turn on one row. Lock waits build up, connection pools run empty, and everything times out at the same time. This is the classic failure of older systems of this kind.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B. DynamoDB conditional counters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UpdateItem&lt;/code&gt; with &lt;code&gt;seats_left &amp;gt; 0&lt;/code&gt; on a counter item, plus a transaction for the duplicate check&lt;/td&gt;
&lt;td&gt;Correct and serverless, but a single counter item is a hot key (one item that every request writes to). One partition limits how many writes per second it can take. Adding a duplicate check means a transaction on every attempt, and even a rejection still costs a write.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C. Virtual waiting room&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Put everyone in a queue and let people in at a fixed rate&lt;/td&gt;
&lt;td&gt;Protects the backend, but it is a whole product to build or buy. Users wait for minutes, and you still need an atomic counter at the end of the queue.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D. One atomic script in Redis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A Lua script checks for a duplicate, checks the level cap and the global cap, and records the admission, all in one call&lt;/td&gt;
&lt;td&gt;Redis runs a script from start to finish before it runs anything else, so no lock is held while waiting on the network. It costs one round trip of microseconds, and a rejection is only a read. The open questions, durability and failover, are answered in Part 2.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsew7rgbteglwhp2n91xo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsew7rgbteglwhp2n91xo.webp" alt="Figure 2: With a row lock, requests wait in line for one row and hold their connections while they wait. With an atomic script, each call runs from start to finish on the Redis thread and holds nothing while waiting on the network." width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We chose D.&lt;/strong&gt; The deciding argument was this: Redis runs commands on a single thread, and that single thread &lt;em&gt;is&lt;/em&gt; the lock. It is held only for the microseconds a script runs, never for a network round trip. The same property makes rejections nearly free. That matters because most of the traffic is rejections.&lt;/p&gt;

&lt;p&gt;The cost is that the source of truth for "who has a seat" now lives in memory. That creates three problems that Part 2 has to solve: a reply lost on the network, a process crash between steps, and a failover that loses the last few writes. We accepted those problems with open eyes. The other options had problems we could not solve within a single burst.&lt;/p&gt;

&lt;p&gt;We also chose the &lt;strong&gt;smallest possible Redis setup&lt;/strong&gt;: one shard (cluster mode disabled), one primary and one replica in different Availability Zones, with automatic failover. The script touches several keys at once. On one shard, that needs no extra work to keep the keys together (no hash tags). As Figure 4 shows later, a single &lt;code&gt;cache.r6g.large&lt;/code&gt; primary never went above 10% engine CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 2: keep the hot path short
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What exactly happens inside a booking request?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The previous system did all its real work inside the request. It wrote the booking to the database, sent the email and updated the counters, all while the user waited. Under a burst, each of those steps becomes a waiting line, and the slowest one sets the latency for everyone.&lt;/p&gt;

&lt;p&gt;We split the work into a &lt;strong&gt;hot path&lt;/strong&gt; (the steps the user waits for) and a &lt;strong&gt;slow path&lt;/strong&gt; (the steps that run after the response is sent).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fau3t24bcj2sqarqzeiu2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fau3t24bcj2sqarqzeiu2.webp" alt="Figure 3: The hot path does one in-memory call, one HTTPS call, and one queue send. Every durable write happens after the response, outside the request." width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the hot path, &lt;code&gt;POST /book&lt;/code&gt; does exactly three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Admit&lt;/strong&gt; the booking in Redis with the atomic script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a payment&lt;/strong&gt; with the payment gateway and get back a payment URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enqueue&lt;/strong&gt; a &lt;code&gt;HELD&lt;/code&gt; record, which means send it to an SQS FIFO queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It then returns &lt;code&gt;202 Accepted&lt;/code&gt; with the payment URL, and the browser redirects to the gateway. If step 2 or step 3 fails, the API undoes the admission before it responds. A failure never leaks a seat (leaves a seat counted with nobody holding it).&lt;/p&gt;

&lt;p&gt;The database is deliberately missing from this list. A worker reads the queue and writes the records into DynamoDB a few seconds later. Payment confirmations, expiry, emails and the back-office sync all run on the slow path. Part 3 covers that path in detail, including why it is safe for the database to be a few seconds behind the seat count.&lt;/p&gt;

&lt;p&gt;The payment gateway call is the one dependency on the hot path that we do not control. It is the only reason the hot path is not a pure in-memory operation. It is also the only slow step, and it shows in the slowest requests: p99 latency was 4.1 s in the first five minutes, when every admitted user was creating a payment at the same time. After that it settled to about 0.3 s. The median stayed at 2 to 5 ms the whole time, because most requests were fast rejections that never reached the gateway. Placing the gateway call &lt;em&gt;after&lt;/em&gt; admission is what makes this work: rejected requests never wait for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 3: keep traffic off the servers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you make three out of four requests never reach your code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cheapest request is the one your servers never see. We put everything we could behind CloudFront.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The page itself&lt;/strong&gt; is a static HTML file in a private S3 bucket, served through CloudFront with Origin Access Control. Page loads never touch a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability counts&lt;/strong&gt; (seats left per level, and whether booking is open or closed) are a small &lt;code&gt;status.json&lt;/code&gt; document in the same bucket. The API rewrites it as the counts change. The browser reads it once when the page loads. After that, the page reacts to API responses instead of asking the server again and again. A &lt;code&gt;429 closed&lt;/code&gt; disables the form, and a &lt;code&gt;409 level full&lt;/code&gt; disables that level. CloudFront serves the file with caching disabled, so readers always see the latest version. The load still lands on S3, not on our servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One origin, one hostname.&lt;/strong&gt; CloudFront routes &lt;code&gt;/book&lt;/code&gt;, &lt;code&gt;/payment/callback&lt;/code&gt;, &lt;code&gt;/payment/webhook&lt;/code&gt; and &lt;code&gt;/health&lt;/code&gt; to the Application Load Balancer, and everything else to S3. The page and the API share one hostname, so the browser sends no CORS preflight (an extra request the browser sends first when the page and the API are on different hosts). Under a burst, skipping the preflight removes a whole extra request from every booking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In front of all of this sits an &lt;strong&gt;AWS WAF web ACL attached to the CloudFront distribution&lt;/strong&gt;. It checks every request at the edge before CloudFront forwards anything.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rate limit on &lt;code&gt;/book&lt;/code&gt;, 2,000 requests per 5 minutes per IP&lt;/td&gt;
&lt;td&gt;Stops a single client from flooding the booking endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limit on &lt;code&gt;/payment/callback&lt;/code&gt;, 100 per 5 minutes per IP&lt;/td&gt;
&lt;td&gt;Makes it impractical to guess payment IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS managed IP reputation list and known-bad-inputs rules&lt;/td&gt;
&lt;td&gt;Drops known attackers and exploit payloads at low cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body size limit on &lt;code&gt;/book&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The form is tiny, so a large request body is never legitimate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The per-IP limits are deliberately generous. Mobile carriers in the region use carrier-grade NAT, which puts many subscribers behind a small number of shared public IPs. A strict per-IP limit would have blocked whole neighbourhoods of real candidates. The rate rules exist to stop abuse, not to control traffic. The admission script already rejects excess load cheaply on its own.&lt;/p&gt;

&lt;p&gt;The load balancer accepts traffic only from CloudFront. Its security group allows CloudFront's origin-facing IP ranges and nothing else. But that list covers &lt;em&gt;every&lt;/em&gt; CloudFront distribution in AWS. An attacker could create their own distribution, point it at our load balancer, and bypass our web ACL. So CloudFront also adds a secret header to every request it sends to the origin. The ALB listener forwards only requests that carry that header. Its default action is a fixed &lt;code&gt;403&lt;/code&gt;. CloudFront talks to the ALB over HTTPS, so the secret never crosses the internet in clear text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 4: capacity for a cliff
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why pre-scale instead of autoscale?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We considered three compute options.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why not (or why)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EC2 Auto Scaling with target tracking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scaling reacts to metrics that are a minute or more behind, then waits for new instances to boot and pass health checks. By the time new capacity arrives, the jump in Figure 1 is already over.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS Lambda behind API Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scales fast, but the burst hits concurrency ramp limits and cold starts at exactly the wrong moment. Every new execution environment also opens its own Redis connections. A traffic spike then becomes a flood of new connections to the one component that must not stall.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A fixed, pre-scaled fleet&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple and predictable. Size it from load tests, add instances hours before opening, and remove them afterwards.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;We pre-scaled a fixed fleet&lt;/strong&gt;: three instances, one per Availability Zone, behind an Application Load Balancer. Each instance ran the API and the queue worker as two systemd services, with a fixed Redis connection pool (300 connections per instance, enough that the burst never waits for a connection). The instances live in private subnets with no public IPs. They reach the payment gateway and the email provider through a NAT gateway in each Availability Zone, and they reach AWS APIs through VPC endpoints. Operators use AWS Systems Manager Session Manager, not SSH.&lt;/p&gt;

&lt;p&gt;We chose the size from load tests before launch. The setup was a k6 load generator in the same region, an allowlist pre-loaded with 40,000 test candidate codes (about double the real seat count), and a mock payment gateway with adjustable latency. The mock let us practise the full booking path at full rate without hitting the real gateway's limits. The tests also shaped several details you will see in Parts 2 and 3.&lt;/p&gt;

&lt;p&gt;On the day, the fleet had far more capacity than it needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevbhvk0o35nsiyem4lx6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevbhvk0o35nsiyem4lx6.webp" alt="Figure 4: CPU during the burst. The API fleet averaged under 12% in the peak five minutes, and the Redis primary's engine CPU stayed under 10%." width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We sized it that way on purpose. The extra capacity cost a few dollars an hour for a few hours. An outage at opening would have cost the client the whole event. With these numbers, the next run could use two Graviton instances and a smaller Redis node and still use less than a third of their capacity at the peak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole architecture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does it look like end to end?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqiydmzehwi8zzibgtgb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqiydmzehwi8zzibgtgb.jpg" alt="Figure 5: The full architecture. CloudFront and the AWS WAF web ACL at the edge; an ALB and a pre-scaled API fleet in private subnets; Redis for admission; SQS FIFO, DynamoDB and Lambda for the slow path." width="800" height="603"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A booking moves through it like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser loads the page and &lt;code&gt;status.json&lt;/code&gt; from &lt;strong&gt;CloudFront&lt;/strong&gt;, which serves both from a private &lt;strong&gt;S3&lt;/strong&gt; bucket. The &lt;strong&gt;AWS WAF web ACL&lt;/strong&gt; on the distribution checks every request.&lt;/li&gt;
&lt;li&gt;The browser posts to &lt;code&gt;/book&lt;/code&gt;. CloudFront forwards the request over HTTPS, with the origin secret header, to the &lt;strong&gt;ALB&lt;/strong&gt; in the public subnets.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;API instance&lt;/strong&gt; in a private subnet checks the gates (is booking open, is the code valid) and runs the admission script on &lt;strong&gt;ElastiCache for Redis&lt;/strong&gt; (primary plus replica across two AZs, TLS and AUTH enabled).&lt;/li&gt;
&lt;li&gt;If admitted, the API creates a payment with the &lt;strong&gt;payment gateway&lt;/strong&gt; (outbound through the NAT gateway) and enqueues a &lt;code&gt;HELD&lt;/code&gt; record on &lt;strong&gt;SQS FIFO&lt;/strong&gt;. It returns the payment URL.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;worker&lt;/strong&gt; on each instance long-polls the queue. (Long-polling means it keeps a request open and waits for messages instead of asking again and again.) It writes the record to &lt;strong&gt;DynamoDB&lt;/strong&gt; with a conditional put. (A conditional put is a write that succeeds only if a condition is true, here that the record does not exist yet.)&lt;/li&gt;
&lt;li&gt;The user pays at the gateway, and the gateway redirects the browser to &lt;code&gt;/payment/callback&lt;/code&gt;. The gateway also sends a signed webhook to &lt;code&gt;/payment/webhook&lt;/code&gt;. Either one can mark the booking &lt;code&gt;PAID&lt;/code&gt;. The API then sends a confirmation email.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;reconciler&lt;/strong&gt; (a background loop inside the API fleet, with only one instance running it at a time) releases holds that were not paid in time. &lt;strong&gt;DynamoDB TTL&lt;/strong&gt; (time to live: DynamoDB deletes a record some time after its deadline passes) and &lt;strong&gt;DynamoDB Streams&lt;/strong&gt; drive an &lt;strong&gt;expiry Lambda&lt;/strong&gt; that acts as a safety net.&lt;/li&gt;
&lt;li&gt;A second stream consumer, the &lt;strong&gt;sync Lambda&lt;/strong&gt;, copies paid bookings into an &lt;strong&gt;RDS&lt;/strong&gt; database for the back-office app. The back-office app also loads the candidate allowlist into Redis before opening.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;CloudWatch alarms&lt;/strong&gt; cover the things that should wake someone up: ALB 5xx errors and unhealthy hosts, Redis CPU, memory, connections and replication lag, queue depth and the dead-letter queue, DynamoDB throttles, Lambda errors and stream iterator age. Application logs go to CloudWatch Logs, so an incident never starts with an SSH session. A few custom metrics sit next to them: admissions per minute, replica-acknowledgement failures (explained in the next post), holds past their deadline, and any difference between the Redis counters and the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch day
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Did it work?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (first hour)&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests reaching the servers&lt;/td&gt;
&lt;td&gt;1.5M+ (about 6.2M at the edge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak throughput at the origin&lt;/td&gt;
&lt;td&gt;54.8K requests per minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median latency&lt;/td&gt;
&lt;td&gt;under 10 ms (2 to 5 ms at the ALB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests served without a server error&lt;/td&gt;
&lt;td&gt;99.99%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seats sold&lt;/td&gt;
&lt;td&gt;20,700 of 20,700, about 95% of them within the first hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double bookings or overbookings&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The number I care about most is the last one. It did not happen by accident. It is the result of the decisions in Part 2, and of months of testing, finding problems and fixing them before launch (Part 3 describes how).&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/sumonselim/designing-a-flash-sale-seat-reservation-system-in-aws-part-2-never-sell-a-seat-twice-4j61"&gt;Part 2&lt;/a&gt; looks inside the admission script. It covers what the script checks and in what order. It explains how the script stays correct when a reply is lost and the client library retries, and how the seat from a crashed request is returned to the pool. It also shows how we closed the short time window in which a Redis failover could silently sell the same seat twice.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>systemdesign</category>
      <category>redis</category>
      <category>lambda</category>
    </item>
  </channel>
</rss>
