<?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 URL Shortener in AWS (Part 3): Writes, Clicks, and the Slow Path</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:59:17 +0000</pubDate>
      <link>https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-3-writes-clicks-and-the-slow-path-3lj3</link>
      <guid>https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-3-writes-clicks-and-the-slow-path-3lj3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The write path does exactly three things: validate the request without touching the network, mint a code, and write the link and its optional idempotency record in &lt;strong&gt;one DynamoDB transaction&lt;/strong&gt;. A retry with the same &lt;code&gt;Idempotency-Key&lt;/code&gt; returns the original link; the same key with a different body returns a conflict. The handler never fetches, resolves or previews the destination, which is the entire defence against being used to reach internal addresses.&lt;/p&gt;

&lt;p&gt;Clicks are written as items to a &lt;strong&gt;DynamoDB &lt;code&gt;Clicks&lt;/code&gt; table&lt;/strong&gt; after the redirect is written, and the table's &lt;strong&gt;stream&lt;/strong&gt; feeds an aggregation Lambda that folds a batch into one write per code on a separate stats table. The first deployed version used Kinesis. At hobby scale the idle stream and its VPC interface endpoint were most of the bill, so four ways of moving clicks were priced against each other and the one with no fixed cost won. The redirect never writes a counter. Counts are stated to be approximate: edge hits never reach Lambda, and at-least-once delivery can add a retry.&lt;/p&gt;

&lt;p&gt;The public API has no accounts in this release, so abuse control is two independent layers: Cloudflare rate-limits link creation per IP, and API Gateway caps aggregate throughput. Takedown is an IAM-authenticated CLI, never an API key.&lt;/p&gt;

&lt;p&gt;The whole codebase is split into a provider-neutral core, small port interfaces, and per-provider adapters. Every package tests against in-memory fakes with no cloud account, no network and no emulator, which is how the design was checked before it was ever deployed.&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-url-shortener-in-aws-part-1-the-architecture-5fjc"&gt;Part 1&lt;/a&gt; covers the architecture and the edge. &lt;a href="https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-2-never-serve-a-dead-link-5ggo"&gt;Part 2&lt;/a&gt; covers how the cache stays correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The create path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What happens inside &lt;code&gt;POST /api/v1/links&lt;/code&gt;?&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%2Fjw2vpd4j87b5dmaa2vi7.png" 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%2Fjw2vpd4j87b5dmaa2vi7.png" alt="Figure 1: Validation is local and cheap. A custom alias goes straight to the transaction. A generated code comes from a leased integer, permuted and Base62-encoded. The transaction writes the link and the idempotency record together, or neither." width="799" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The request is a JSON body with one required field, &lt;code&gt;long_url&lt;/code&gt;, and two optional ones, &lt;code&gt;alias&lt;/code&gt; and &lt;code&gt;expires_in&lt;/code&gt;. The handler does the following, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bound the body&lt;/strong&gt; at 16 KiB before decoding. A URL is at most 2,048 characters, so a larger body is never legitimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the URL&lt;/strong&gt; without a network call. It must parse, use &lt;code&gt;http&lt;/code&gt; or &lt;code&gt;https&lt;/code&gt;, have a non-empty host, carry no userinfo and no control characters, and fit in 2,048 characters. Anything else is &lt;code&gt;400 INVALID_URL&lt;/code&gt;. The scheme allowlist is what keeps &lt;code&gt;javascript:&lt;/code&gt;, &lt;code&gt;data:&lt;/code&gt; and &lt;code&gt;file:&lt;/code&gt; out of a &lt;code&gt;Location&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the alias&lt;/strong&gt;, if given: 3 to 32 characters from letters, digits, hyphen and underscore, and not &lt;code&gt;api&lt;/code&gt; or &lt;code&gt;app&lt;/code&gt;, which are routing prefixes at the edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the lifetime&lt;/strong&gt;: 60 seconds to 5 years, with 5 years as the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the idempotency key&lt;/strong&gt;, if the header is present: 1 to 128 visible ASCII characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mint the code&lt;/strong&gt;: the alias as given, or a leased integer permuted and encoded as described in Part 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write one transaction&lt;/strong&gt; and map its outcome to a response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The handler never fetches the target URL.&lt;/strong&gt; No liveness check, no &lt;code&gt;HEAD&lt;/code&gt; request, no preview. A service that never makes an outbound request to a user-supplied URL cannot be tricked into fetching the instance metadata endpoint or an internal address on the caller's behalf, because it never fetches anything at all. Malicious destinations are therefore an asynchronous, post-creation concern (see Abuse below), not a write-path gate. This is also why the API function needs no VPC and no NAT gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 1: one transaction, three outcomes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How does a retry get the same answer as the original?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clients retry. A mobile app that times out on create will send the same request again, and without help it will create a second link. The standard answer is an &lt;code&gt;Idempotency-Key&lt;/code&gt; header, and the interesting part is what it is scoped to and what it is compared against.&lt;/p&gt;

&lt;p&gt;I compared 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;Store the key in the link item and query by it&lt;/td&gt;
&lt;td&gt;Needs a secondary index on every link for a field most links do not have, and a race between two concurrent requests with the same key can still create two links.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write the idempotency record first, then the link&lt;/td&gt;
&lt;td&gt;If the process dies between the two writes, the key points at a link that does not exist, and every retry returns a 404 for a link the client thinks it created.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One transaction over both items (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TransactWriteItems&lt;/code&gt; puts the link on the condition that the code is absent, and the idempotency record on the condition that the key is absent. Both succeed or neither does. There is no state in which the key exists without the link.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The idempotency record holds the short code, a 24-hour TTL, and a hash of the request. The hash is SHA-256 over the validated &lt;code&gt;{long_url, alias, expires_in}&lt;/code&gt; struct with defaults filled in, never over the raw JSON bytes, so whitespace and key order do not matter but a changed body does.&lt;/p&gt;

&lt;p&gt;A cancelled transaction has to be classified, 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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqis1clqpydve7v7xvfa0.png" 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%2Fqis1clqpydve7v7xvfa0.png" alt="Figure 2: When the transaction is cancelled and an idempotency key was given, the adapter reads the stored record with a consistent read. The same hash replays the original link. A different hash is a conflict. No record means the link condition failed, which is a code collision." width="799" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No idempotency key on the request:&lt;/strong&gt; the only condition that can fail is the link's. For an alias that is &lt;code&gt;409 ALIAS_TAKEN&lt;/code&gt;. For a generated code it is a collision with an alias someone chose earlier, so the handler takes the next integer from its block and tries again, up to 32 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key given, record found with the same hash:&lt;/strong&gt; a retry. Read the stored link and return the original &lt;code&gt;201&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key given, record found with a different hash:&lt;/strong&gt; the same key reused for a different request. &lt;code&gt;409 IDEMPOTENCY_CONFLICT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key given, no record:&lt;/strong&gt; the idempotency put succeeded in some earlier attempt that we never heard about? No: if it had, the record would be there. So the link put is what failed, and the outcome is a collision, handled as above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The read after the cancel is strongly consistent. An eventually consistent read could miss a record written milliseconds earlier and turn a legitimate retry into a false collision.&lt;/p&gt;

&lt;p&gt;In the current release the API is public and has no accounts, so the idempotency scope is effectively global. The record key is built as &lt;code&gt;{owner_id}#{key}&lt;/code&gt; with an empty owner, so that when authenticated ownership returns, the scope narrows to one caller without a data migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 2: clicks off the hot path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you count 10 billion clicks a month without a counter on the hot row?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Part 1 explained why the click counter is not on the link item. Something has to carry each click from the redirect to a batch writer, and the choice of that something turned out to be the most expensive decision in the deployed system, in the least expected direction.&lt;/p&gt;

&lt;p&gt;The first deployed version used the textbook shape: the redirect published each click to a &lt;strong&gt;Kinesis Data Streams&lt;/strong&gt; stream, an aggregation Lambda consumed it, and &lt;strong&gt;Firehose&lt;/strong&gt; archived the raw events to S3. It worked. Then the first month's bill arrived, and the Kinesis line was larger than every other line combined. Not because of traffic, which rounded to zero, but because an on-demand stream bills about $0.04 an hour for existing, and the redirect Lambda, being inside a VPC, needed a Kinesis &lt;strong&gt;interface endpoint&lt;/strong&gt; at about $7.30 per AZ-month just to reach it. About $36 a month of fixed cost to carry a few hundred clicks.&lt;/p&gt;

&lt;p&gt;That prompted a proper comparison. Four ways to move a click, priced at the two scales that matter: the deployed hobby scale, where volume is near zero and fixed cost is everything, and the design target of 5 billion clicks a month reaching Lambda, where cost per event is everything.&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;Fixed cost per month&lt;/th&gt;
&lt;th&gt;Cost at 5B clicks a month&lt;/th&gt;
&lt;th&gt;What you get, and what you give up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Kinesis Data Streams&lt;/strong&gt; plus Firehose archive&lt;/td&gt;
&lt;td&gt;~$36 (on-demand stream hours plus one interface endpoint)&lt;/td&gt;
&lt;td&gt;~$250&lt;/td&gt;
&lt;td&gt;Ordered, multi-consumer, built for this volume. Partial-batch failure handling in the event source mapping. Needs an interface endpoint from a VPC function, and bills for existing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQS standard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$7 (interface endpoint; SQS has no gateway endpoint)&lt;/td&gt;
&lt;td&gt;~$2,000 (one request per click plus receives)&lt;/td&gt;
&lt;td&gt;Simple and near-free per message, but a VPC function still needs an interface endpoint. One consumer only, so no archive.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DynamoDB &lt;code&gt;Clicks&lt;/code&gt; table plus DynamoDB Streams&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0 (gateway endpoint is free; Lambda reads from Streams are free)&lt;/td&gt;
&lt;td&gt;~$6,250 (one on-demand write per click)&lt;/td&gt;
&lt;td&gt;Keeps the batch aggregator unchanged. The items are the archive, expiring by TTL. Highest cost per click by far.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Direct &lt;code&gt;UpdateItem ADD&lt;/code&gt; on &lt;code&gt;LinkStats&lt;/code&gt;&lt;/strong&gt; from the redirect&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;~$6,250 (one write per click)&lt;/td&gt;
&lt;td&gt;No pipeline at all. But a per-click write on the hot path, no batching to smooth a viral spike onto one item, no raw events, and no way to add a second consumer later.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;I chose the DynamoDB table with Streams.&lt;/strong&gt; At the scale the system actually runs, the two zero-fixed-cost options cost nothing, and the table wins between them because it keeps everything the aggregator was built to do: batching per code, partial-batch retries, bisecting on error, and the on-failure queue. The raw items also replace the S3 archive, queryable for 90 days and then gone by TTL. The whole change was one adapter, one Lambda entrypoint, and one Terraform module, which is what the port structure described in Challenge 4 is for.&lt;/p&gt;

&lt;p&gt;The trade is honest and it is written into the design document: at the design target, one write per click costs about 25 times what Kinesis would. If real traffic ever approaches the envelope, the Kinesis shape is the right one again, and switching back is the same one-adapter change. A design that is right for 5 billion clicks and wrong for 500 is not the right design to deploy for 500.&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%2F9avv60rfnj9ia83rgxx3.png" 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%2F9avv60rfnj9ia83rgxx3.png" alt="Figure 3: The redirect writes one item per click, after the 302, through the free gateway endpoint. The table's stream, filtered to inserts, feeds an aggregation Lambda that folds a batch into one UpdateItem per code. Failures go to an on-failure queue with an alarm. TTL removes raw items after 90 days without invoking anything." width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The item is small and private.&lt;/strong&gt; &lt;code&gt;{event_id, short_code, occurred_at, source_ip_hash, ttl}&lt;/code&gt;. The source address is HMAC-SHA-256 hashed with a key that changes daily, before it leaves the function, so the same visitor can be counted within a day but never linked across days. No user agent, no referrer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The write has a 500 ms budget and happens after the 302.&lt;/strong&gt; A warm call through the VPC endpoint takes about 20 ms. A cold container's first call, TLS handshake included, was observed to exceed 100 ms and drop the click, which is why the budget is 500 ms rather than 100. The redirect decision is already made and written by then; a failed write is logged and lost, never retried inside the request, and never turned into an error for the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stream carries inserts only.&lt;/strong&gt; The event source mapping filters on &lt;code&gt;eventName = INSERT&lt;/code&gt;, so the TTL sweep's &lt;code&gt;REMOVE&lt;/code&gt; records, which arrive on the same stream 90 days later, never invoke the function or count anything. The function checks the event name again anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The aggregator folds a batch into one write per code.&lt;/strong&gt; It reads up to 100 records or 5 seconds' worth, groups them by the &lt;code&gt;short_code&lt;/code&gt; in each item's image, and issues &lt;code&gt;UpdateItem ADD clicks :n SET last_click_at&lt;/code&gt; once per code. A million clicks on one link in five minutes become a few hundred writes. It returns partial-batch failures so only the failed records are retried, with two retries, bisecting the batch on error, and an encrypted SQS on-failure queue that alarms on a single message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The event ID is the partition key&lt;/strong&gt; of the Clicks table, so writes spread evenly and a viral code cannot make one hot partition. Nothing reads the table by code; the stream is the only reader.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Counts are approximate, and the API says so.&lt;/strong&gt; CloudFront hits never invoke Lambda, so popular links are undercounted. Streams delivery to Lambda is at-least-once, so a retried batch can count a click twice. A per-click deduplication check would double the writes and undo the point of the pipeline. The stats endpoint promises an eventually consistent operational estimate, not a billing-grade count. Reconciling against CloudFront logs is follow-up work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 3: abuse controls without accounts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you protect a public API that has no callers to identify?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first design had API keys: a credentials table, hashed tokens, per-owner quotas through API Gateway usage plans, and an owner check on stats and delete. The scaffolding for that is still in the code. The deployed release removed the requirement, because a shortener that needs a key before it will shorten a URL is not useful to a visitor with a link to share. That decision moves every abuse control to layers that do not need an identity.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;Why it is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare rate limit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /api/v1/links&lt;/code&gt; per client IP: a handful of requests per 10-second window, then blocked for 10 seconds&lt;/td&gt;
&lt;td&gt;The real control on anonymous creation. The zone is on the free plan, which allows one rule with a fixed window, so it is tuned tight, and a blocked request never reaches AWS at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare WAF&lt;/td&gt;
&lt;td&gt;Managed rules on every request&lt;/td&gt;
&lt;td&gt;Drops known-bad traffic at the edge before it is billed anywhere else.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway stage throttle&lt;/td&gt;
&lt;td&gt;Total requests per second and burst across all callers&lt;/td&gt;
&lt;td&gt;A blunt aggregate ceiling that protects the Lambda concurrency pool and the DynamoDB counter. It is not per-IP.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strict input validation&lt;/td&gt;
&lt;td&gt;Code charset and length on every redirect, body size and URL rules on every create&lt;/td&gt;
&lt;td&gt;Bounds what probing and junk can cost before any storage access.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operator takedown&lt;/td&gt;
&lt;td&gt;Soft delete plus tombstone, by an IAM-authenticated CLI with a mandatory reason&lt;/td&gt;
&lt;td&gt;The response to a malicious destination. Identity comes from STS, never from a flag or a key.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What is deliberately missing is automated reputation checking. The service does not look up destinations against a blocklist, and it does not fetch them. Both would be asynchronous consumers of a creation event, outside the write path, and both are follow-up work. The first release provides the rate controls, the audit trail and a tested takedown, and states that.&lt;/p&gt;

&lt;p&gt;The stats endpoint has one small rule that is easy to miss. A taken-down link returns 404 from stats, not its click history. Confirming that a deleted code once existed, and how popular it was, is information a taken-down link should not give away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 4: a codebase that can be tested without a cloud
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How was this checked before it was deployed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The seat reservation system had a launch date, months of load tests, and a mock payment gateway. This project had none of those, and one person. The strategy that replaced them is structural: make every piece of logic testable with no cloud account, and make the cloud-specific parts as thin as possible.&lt;/p&gt;

&lt;p&gt;The code is split into three kinds of package:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;core&lt;/code&gt;&lt;/strong&gt; holds the pure logic: Base62 encoding, the keyed permutation, validation, click aggregation, and the redirect decision. It imports nothing outside the standard library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;platform&lt;/code&gt;&lt;/strong&gt; declares small interfaces, called ports: the link store, the stats store, the cache, the cache invalidator, the event publisher, the ID allocator, the audit sink, and a clock. Nothing here talks to a cloud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;handlers&lt;/code&gt;&lt;/strong&gt; implements the HTTP contract as plain &lt;code&gt;net/http&lt;/code&gt; handlers that depend only on the ports. They never import a Lambda type or an AWS SDK.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything AWS-specific lives in adapters that implement the ports, and in two thin Lambda entrypoints that wire adapters into handlers. The same handlers run in a single local process with in-memory adapters, which is what the web UI is developed against.&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%2Fi46hdiczwrzznm38tahg.png" 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%2Fi46hdiczwrzznm38tahg.png" alt="Figure 4: The layers of testing. The first four run in Docker or CI with no cloud credentials. The last two need a deployed non-production stack and an operator." width="800" height="554"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each layer answers a different question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Core tests&lt;/strong&gt; are table-driven: known Base62 vectors, round-trips over a range of integers, the permutation's bijectivity on a sampled range plus the boundaries &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;62^7 - 1&lt;/code&gt;, every validation edge, and every redirect branch. A benchmark covers permute-plus-encode, the allocator's hot path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handler tests&lt;/strong&gt; run every endpoint end to end against the in-memory fakes: create with and without an alias, create with a retried key, a conflicting key, a code collision that retries, every cache outcome from Part 2 including a failing store, and a takedown whose invalidation fails and then succeeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adapter tests&lt;/strong&gt; run against local fakes in-process: a small fake DynamoDB server that returns the same cancellation reasons the real one does, and a fake Redis server for the version script and the tombstone shape. The click store and the stream consumer are tested the same way: the item written, the TTL on it, and a batch with a malformed record, a failing code and a TTL removal in it. Nothing in the default test run needs DynamoDB Local, LocalStack or a network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure checks&lt;/strong&gt; run on every pull request: formatting, validation of every module and environment root with no backend and no credentials, and a read-only Terraform plan posted as a comment on any pull request that touches the AWS directory. Apply runs only from a version tag, through an OIDC role, never from a merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The load envelope&lt;/strong&gt; is two k6 scripts, one for 15,432 redirects a second and one for 154 creates a second, each with p99 and error-rate thresholds that abort the run. They run only from an operator workstation against a non-production stack. They are the envelope the design was sized for, and the runbook says so; they are not a record of a launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An audit of the live stack&lt;/strong&gt; closed the gaps that only a deployment shows: a Function URL that needed a second IAM permission the documentation did not mention, a KMS key policy that had to name CloudFront before the UI bucket would serve, reserved words in a DynamoDB update expression, and the origin check that has to answer 403 on the bare CloudFront hostname before the domain is switched over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deployed system is sized for hobby scale, not for the design targets: no provisioned concurrency, one Redis node, on-demand Kinesis, point-in-time recovery off, and the smallest instance sizes throughout. Every module has the production sizes as documented alternatives, and the runbook lists what to raise, in what order, if traffic ever approaches the envelope.&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 hot path first, and keep everything else out of it.&lt;/strong&gt; A redirect is one cache read, one table read on a miss, and one event after the response. Anything that wants to be inside that request has to justify itself against the p99 target, and a click counter never will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the assumptions of the standard answer against your runtime.&lt;/strong&gt; Snowflake IDs are the textbook choice, and they are wrong on Lambda because Lambda has no stable worker ID. Block leasing with a permutation is less famous and fits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every cache record a version, and compare it in the same atomic step as the write.&lt;/strong&gt; A cache that can be overwritten by a late writer will eventually serve something you deleted. A tombstone is only as good as the write that protects it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve stale only inside a window you can defend, and never over a positive "no".&lt;/strong&gt; Availability during a database failure is worth 15 minutes of bounded staleness. It is not worth resurrecting a link that was taken down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the delete finish in the cache.&lt;/strong&gt; The durable write is not the end of a delete. The operation is done when every tier that could still answer "yes" has been told "no", and until then it should say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never fetch what the user gave you.&lt;/strong&gt; The simplest defence against server-side request forgery is a write path that makes no outbound requests at all. It also removes a NAT gateway from the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what the numbers mean.&lt;/strong&gt; Click counts here are estimates, and the stats API says so. A design that hides its accuracy bounds gets a reconciliation project later, under pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure the code so the cloud is optional.&lt;/strong&gt; Ports, fakes and pure functions meant every correctness property in this series was tested before an account existed. The live stack still found things, but they were configuration gaps, not logic bugs. The same structure is what made swapping the click pipeline a one-adapter change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price the design at the scale you will actually run, not only the scale you designed for.&lt;/strong&gt; The textbook click pipeline was the cheapest per event and the most expensive per month. Fixed costs dominate a small system the way per-request costs dominate a large one, and the bill is the only test that catches it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a system whose design targets are 10 billion redirects a month with a p99 under 50 ms, running today at a fraction of that on the smallest sizes of every service, with the same code and the same tests.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>go</category>
    </item>
    <item>
      <title>Designing a URL Shortener in AWS (Part 2): Never Serve a Dead Link</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:58:41 +0000</pubDate>
      <link>https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-2-never-serve-a-dead-link-5ggo</link>
      <guid>https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-2-never-serve-a-dead-link-5ggo</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The redirect path serves almost every click from cache, so the cache has to be right about two things: a new link must be visible on its first click, and a deleted link must never come back. Both come down to what a cache record holds and how it is written.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three timestamps, not one TTL.&lt;/strong&gt; A record is fresh for up to 24 hours, then stale for 15 minutes more, and the Redis key itself never outlives the link's own expiry. Stale means "check DynamoDB first", not "serve it".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every write compares versions.&lt;/strong&gt; DynamoDB increments the link's version on every state change. A Redis write is one Lua script that refuses to overwrite a record with a higher version. A slow populate can never undo a tombstone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tombstones carry no destination.&lt;/strong&gt; A deleted link is cached as &lt;code&gt;deleted&lt;/code&gt; with its version and a 24-hour lifetime. It answers 404 without touching the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale is a fallback, not a feature.&lt;/strong&gt; A stale active record is served only when DynamoDB fails, only within the 15-minute grace, and never over a tombstone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Takedown finishes in Redis, not in DynamoDB.&lt;/strong&gt; The durable soft delete happens first. The operation is not reported as done until a tombstone with that version is in the cache. The edge entry ages out on its own within 5 seconds.&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-url-shortener-in-aws-part-1-the-architecture-5fjc"&gt;Part 1&lt;/a&gt; explains the architecture and why the redirect sits behind three cache tiers. &lt;a href="https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-3-writes-clicks-and-the-slow-path-3lj3"&gt;Part 3&lt;/a&gt; covers link creation, click analytics, and testing.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What exactly does "never serve a dead link" mean?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before writing the cache, I 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;A deleted link stays deleted.&lt;/strong&gt; Once a delete or takedown is reported as successful, no later click is redirected to that destination, apart from a 302 already cached at the edge for at most 5 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new link is visible at once.&lt;/strong&gt; The first click after creation, including a custom alias created a moment after someone tried it, is served, never hidden behind a cached miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An expired link is not served.&lt;/strong&gt; The link's own &lt;code&gt;expires_at&lt;/code&gt; is checked on every path, in the cache and in the table, and never delegated to a background cleanup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Invariant 1 is the one that a naive cache breaks most easily. Invariant 2 is the reason unknown codes are never cached. Invariant 3 is the reason DynamoDB TTL is treated as cleanup, not as a correctness gate.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What lives in DynamoDB, and what lives in Redis?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB is authoritative. Redis is disposable: it can be flushed at any time and rebuilt from the table by ordinary traffic. Neither of those statements is true unless the record formats make them true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Links item.&lt;/strong&gt; One item per code, no sort key, so every redirect is a single-digit-millisecond &lt;code&gt;GetItem&lt;/code&gt; on the exact key.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&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;short_code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Partition key. A generated code or a custom alias.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;long_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The validated destination. Immutable after creation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;False once deleted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starts at 1. Incremented atomically on every state change. This is what makes cache writes safe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The link's logical end. Checked on every read.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;purge_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The DynamoDB TTL attribute. Equals &lt;code&gt;expires_at&lt;/code&gt; for active links, and &lt;code&gt;deleted_at&lt;/code&gt; plus 30 days for deleted ones.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DynamoDB deletes expired items on a best-effort schedule, sometimes days late. So &lt;code&gt;purge_at&lt;/code&gt; is cleanup only. The read path also requires &lt;code&gt;now &amp;lt; expires_at&lt;/code&gt; and &lt;code&gt;is_active&lt;/code&gt;, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Redis record.&lt;/strong&gt; Key &lt;code&gt;url:{code}&lt;/code&gt;, a small JSON document:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&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;state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;active&lt;/code&gt; or &lt;code&gt;deleted&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;long_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Present only on active records. A tombstone never carries a destination.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copied from the DynamoDB item at the time of the write.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The link's own end, copied from the item.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fresh_until&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Serve from cache alone until this moment. At most 24 hours after populate, and never past &lt;code&gt;expires_at&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stale_until&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Redis key's own TTL. 15 minutes after &lt;code&gt;fresh_until&lt;/code&gt; for active records, 24 hours after &lt;code&gt;deleted_at&lt;/code&gt; for tombstones.&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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7fq3mdhlepd5zztufp6.png" 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%2Fu7fq3mdhlepd5zztufp6.png" alt="Figure 1: An active record is fresh for up to 24 hours, then stale for 15 minutes, then gone. The key's TTL is capped at the link's own expiry, so the record cannot outlive the link. A tombstone lives 24 hours and carries no URL." width="800" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why three timestamps instead of one TTL? Because they answer three different questions. &lt;code&gt;expires_at&lt;/code&gt; asks whether the link is alive. &lt;code&gt;fresh_until&lt;/code&gt; asks whether the cache may answer alone. &lt;code&gt;stale_until&lt;/code&gt; asks whether the cache is allowed to hold anything at all. Folding them into one number forces one of two bad trade-offs: either a short TTL that sends most clicks to the table, or a long TTL that serves a deleted link for its whole length.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lookup
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does the redirect do with each cache outcome?&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%2Ff2l09p4r7px7fyymxym5.png" 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%2Ff2l09p4r7px7fyymxym5.png" alt="Figure 2: The cache read has five outcomes. Only fresh and deleted are answered from Redis alone. Stale, miss and a Redis error all go to DynamoDB, whose answer decides what to cache and what to return. A read failure has one narrow fallback." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The handler first checks the code against the alias rule (3 to 32 characters, letters, digits, hyphen, underscore). Anything else is a 404 before any lookup, which bounds what probing can cost. Then it reads Redis and classifies the record against the current time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fresh active:&lt;/strong&gt; return 302 with &lt;code&gt;Cache-Control: public, max-age=5&lt;/code&gt;. This is the common case and the only path that costs one round trip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleted:&lt;/strong&gt; return 404 with &lt;code&gt;no-store&lt;/code&gt;. No table read. A tombstone is a positive answer, not a miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale active:&lt;/strong&gt; revalidate. Read DynamoDB and apply whatever it says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Miss, or expired by its own timestamps:&lt;/strong&gt; read DynamoDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis error or timeout:&lt;/strong&gt; treat as a miss. The Redis client has a 150 ms budget and retries disabled, so a failover costs at most 150 ms before the table read starts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After a DynamoDB read, the outcome is decided once, in one pure function, from the item's &lt;code&gt;is_active&lt;/code&gt;, &lt;code&gt;expires_at&lt;/code&gt; and the current time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active and not expired:&lt;/strong&gt; populate Redis with an active record and return 302.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown or expired:&lt;/strong&gt; return 404 and cache nothing. This is invariant 2. A cached miss would hide an alias created a second later. Expired links are not cached either, because a tombstone for a link that simply ran out would be a 24-hour entry for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inactive:&lt;/strong&gt; write a tombstone and return 404. From now on the table is not consulted for this code for 24 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything above is deterministic. The one judgement call is what to do when DynamoDB itself fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 1: the database is down and the cache is old
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When is it safe to serve a stale destination?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB is very reliable, but the redirect function talks to it through a VPC endpoint with a 1-second budget, and throttles or a slow partition on a viral key are real. When the revalidation read fails, there are 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;Problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Return 503&lt;/td&gt;
&lt;td&gt;Every click on a link whose cache entry passed &lt;code&gt;fresh_until&lt;/code&gt; in the last few minutes fails, while a perfectly good destination sits in Redis.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serve the stale record, whatever it is&lt;/td&gt;
&lt;td&gt;A deleted link whose tombstone was never written, or whose cache entry is from before the delete, redirects again. That breaks invariant 1.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Serve the stale record only inside a bounded window (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The stale destination is served only if the record is &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;now &amp;lt; stale_until&lt;/code&gt;, and &lt;code&gt;now &amp;lt; expires_at&lt;/code&gt;. A tombstone is always a 404. Anything else is 503 with &lt;code&gt;no-store&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The window is the 15 minutes between &lt;code&gt;fresh_until&lt;/code&gt; and &lt;code&gt;stale_until&lt;/code&gt;. Within it, the worst case is that a link deleted in the last 15 minutes, whose tombstone write also failed, is served during a DynamoDB outage. That is a double failure of two independent systems inside a short window, and the alternative is failing every revalidating click for the length of the outage. I accepted it, and the handler logs a warning on every stale serve so the count is visible.&lt;/p&gt;

&lt;p&gt;What the window never does is override a tombstone. If Redis says &lt;code&gt;deleted&lt;/code&gt;, the answer is 404 no matter what DynamoDB is doing. The tombstone is a positive fact, and the stale path only ever fills in for a missing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 2: the slow writer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can a cache populate undo a delete?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, if writes are plain &lt;code&gt;SET&lt;/code&gt;s. The redirect function and the invalidation function both write the same key, and they are not coordinated. Consider a click and a takedown that overlap:&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%2Fn15s2syi9vhpof8rjqmw.png" 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%2Fn15s2syi9vhpof8rjqmw.png" alt="Figure 3: Left: a redirect reads the active item from DynamoDB, a takedown writes the tombstone, then the redirect's populate lands and replaces it. The deleted link redirects for up to 24 hours. Right: every write runs one Lua script that rejects a lower version, so the tombstone survives." width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The redirect read the item while it was still active. The takedown ran, wrote the DynamoDB update and the Redis tombstone. Then the redirect's populate, carrying the old active record, arrived and overwrote the tombstone. The link is dead in the table and alive in the cache for the next 24 hours. Every click is a cache hit, so nothing ever asks the table again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a version on every record and a compare on every write.&lt;/strong&gt; DynamoDB increments &lt;code&gt;version&lt;/code&gt; in the same conditional update that flips &lt;code&gt;is_active&lt;/code&gt;. Both the redirect and the invalidation function copy that version into the record they write. The write itself is one Lua 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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;existing&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;'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;1&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;existing&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;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;pcall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cjson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&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;ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'table'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'version'&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;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;decoded&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'version'&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="mi"&gt;0&lt;/span&gt;                                  &lt;span class="c1"&gt;-- stored record is newer: keep it&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;end&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;'SET'&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="s1"&gt;'PX'&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;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="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;Redis runs a script from start to finish before anything else, so the read and the write cannot be separated by another writer. A separate &lt;code&gt;GET&lt;/code&gt; followed by a &lt;code&gt;SET&lt;/code&gt; in Go would reopen exactly the race in the figure. Equal versions are allowed to overwrite, so a retried write is idempotent (safe to run more than once, with the same result), and an active populate carrying the same version as the stored active record simply refreshes its timestamps.&lt;/p&gt;

&lt;p&gt;The rule I took from this is the same one that decided the seat reservation system's admission script: &lt;strong&gt;a guard and the action it guards must live in the same atomic step.&lt;/strong&gt; A check followed by a separate write is a race, and under load every race is lost sooner or later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 3: the delete that only half happened
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What if the table is updated but the cache is not?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A delete has two writes in two systems, and the second one can fail: the invalidation function can time out, Redis can be mid-failover, the invoke can be throttled. If the operation reports success anyway, the link is dead in DynamoDB and alive in Redis for up to 24 hours. That is invariant 1 broken by a partial failure rather than by a race.&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%2Fea3cygdinvkbdevzzir1.png" 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%2Fea3cygdinvkbdevzzir1.png" alt="Figure 4: The admin CLI takes its identity from STS, soft-deletes the item with a conditional update, then synchronously invokes the invalidation function, which writes the versioned tombstone. Only then is the audit event recorded as a success. If invalidation fails, the call returns 503 and must be retried." width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order is deliberate, and each step is idempotent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity comes from AWS, not from a flag.&lt;/strong&gt; The admin CLI calls &lt;code&gt;GetCallerIdentity&lt;/code&gt; and uses the returned ARN as the actor. There is no &lt;code&gt;--actor&lt;/code&gt; option to forge. A reason is mandatory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The durable delete is a conditional update.&lt;/strong&gt; &lt;code&gt;is_active&lt;/code&gt; becomes false and &lt;code&gt;version&lt;/code&gt; becomes &lt;code&gt;old + 1&lt;/code&gt;, on the condition that the item is still active with the old version. A retry after a partial failure finds the item already inactive and returns the stored deletion, with its version and timestamps, instead of failing. A retry by a different actor is refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidation is synchronous.&lt;/strong&gt; The API role and the operator role are the only principals allowed to invoke the invalidation function, and they invoke it with &lt;code&gt;RequestResponse&lt;/code&gt; and wait. The payload is &lt;code&gt;{short_code, version, deleted_at}&lt;/code&gt;. Success means the tombstone with that version is in Redis. An older invalidation can never overwrite a newer record, because of the script above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed invalidation is a failed delete.&lt;/strong&gt; The operation returns 503, records an audit event with outcome &lt;code&gt;invalidate_failed&lt;/code&gt;, and raises the alarm that security owns. The caller retries. The retry skips the state change, reissues the same tombstone, and returns success once it lands. The item is never reported deleted while a known positive cache entry may remain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The edge takes care of itself.&lt;/strong&gt; CloudFront caches a 302 for 5 seconds and nothing else. There is no invalidation API call, no distribution-wide purge, and no &lt;code&gt;stale-while-revalidate&lt;/code&gt; that could extend the window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same service runs an owner's own delete when authenticated ownership returns. In the current release, create and stats are public, so there is no owner to authorise, and the only delete is the operator's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not DAX, and why not a simpler Redis
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Was all of this necessary?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Part 1 listed the reasons DAX could not replace Redis here: no explicit deleted state, no version comparison, no bounded stale window. It is worth restating them from this side, because each one maps to an invariant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Invariant&lt;/th&gt;
&lt;th&gt;What the cache must support&lt;/th&gt;
&lt;th&gt;DAX&lt;/th&gt;
&lt;th&gt;Redis with this record&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A deleted link stays deleted&lt;/td&gt;
&lt;td&gt;A positive "deleted" answer that survives late writers&lt;/td&gt;
&lt;td&gt;No: a deleted item is just a miss, and a miss goes to the table&lt;/td&gt;
&lt;td&gt;Tombstone plus version compare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new link is visible at once&lt;/td&gt;
&lt;td&gt;No negative caching of misses&lt;/td&gt;
&lt;td&gt;Item cache only, so misses are not cached, which is fine&lt;/td&gt;
&lt;td&gt;Unknown codes are never written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An expired link is not served&lt;/td&gt;
&lt;td&gt;The record carries the link's own expiry and the read checks it&lt;/td&gt;
&lt;td&gt;The item carries it, but the cache TTL is separate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expires_at&lt;/code&gt; in the record, checked on every read, and the key TTL is capped at it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A simpler Redis layout would also have worked for the first two rows, using two keys per code or a plain &lt;code&gt;SET&lt;/code&gt; with a short TTL. The third row and Failure mode 2 are what forced the versioned single record. Once every write is one script that compares versions, the tombstone, the stale window and the takedown retry all fall out of the same mechanism.&lt;/p&gt;

&lt;p&gt;Two configuration details finish the picture. Redis runs with in-transit and at-rest encryption and an AUTH token held in an encrypted parameter. And eviction is allowed: any record can be evicted under memory pressure, because eviction is just a miss, and a miss goes to DynamoDB. That is the opposite of the seat reservation cache, where eviction would have lost the source of truth. Here the source of truth is the table, and the cache is only ever a copy.&lt;/p&gt;

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

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The redirect decision is a pure function&lt;/strong&gt; with a table test for every branch: active and not expired, active and expired, inactive, unknown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The handler is tested against in-memory fakes&lt;/strong&gt; of the store, the cache and the publisher, for every outcome in Figure 2: fresh, stale with a healthy table, stale with a failing table inside and outside the grace window, tombstone with a failing table, miss, and a Redis error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Redis adapter is tested against a fake Redis server in-process.&lt;/strong&gt; The tests cover the version script directly: a lower version is rejected, an equal version is accepted, a tombstone survives a late active write, and the key TTL never exceeds the link's own expiry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The takedown path is tested for partial failure:&lt;/strong&gt; a delete whose invalidation fails returns a dependency error, records the failed-invalidation audit event, and succeeds on retry without a second state change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these tests needs a cloud account, a network, or a running emulator. Part 3 explains why the code is structured to make that possible.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-3-writes-clicks-and-the-slow-path-3lj3"&gt;Part 3&lt;/a&gt; follows a link from creation to its click count. It covers how a create request is validated without ever fetching the destination, how an idempotency key and a generated code are written in one transaction, how the click stream is kept off the redirect and aggregated in batches, what the abuse controls are for a public API with no accounts, and how the whole thing is tested in layers without a cloud account.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>go</category>
    </item>
    <item>
      <title>Designing a URL Shortener in AWS (Part 1): The Architecture</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:58:40 +0000</pubDate>
      <link>https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-1-the-architecture-5fjc</link>
      <guid>https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-1-the-architecture-5fjc</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://mol.la" rel="noopener noreferrer"&gt;mol.la&lt;/a&gt; is an open-source URL shortener I built in Go on AWS. The design target is 100 million new links a month with a 100:1 read-to-write ratio, a p99 redirect under 50 ms at the edge, and 99.9% availability for the redirect path. The whole design follows three rules.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;the redirect touches as little as possible&lt;/strong&gt;. A click is answered by CloudFront if the same code was seen in the last 5 seconds, then by Redis, and only on a double miss by DynamoDB. Click counting happens after the response, never inside it.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;short codes come from a leased block of integers, not a hash and not a Snowflake ID&lt;/strong&gt;. Each Lambda execution environment takes 1,000 integers from one DynamoDB counter with a single atomic &lt;code&gt;ADD&lt;/code&gt;, then hides the sequence behind a keyed permutation before Base62 encoding. Lambda has no stable worker ID, which is the one assumption Snowflake needs.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;the edge is two layers, and nothing reaches the origin without proof it came through both&lt;/strong&gt;. Cloudflare terminates TLS and runs the firewall. CloudFront routes by path and caches redirects. A shared secret header, stamped by Cloudflare and checked by a CloudFront function, closes the direct path to the distribution.&lt;/p&gt;

&lt;p&gt;The system is deployed and serving traffic, at hobby scale, on the smallest sizes of every managed service. The numbers in this series are the design targets that the code and the load scripts were built for, not measurements from a launch event. Where a number is a model rather than a measurement, the text says so.&lt;/p&gt;

&lt;p&gt;This is Part 1 of a three-part series, written as a design reference: the requirements, the options I compared, and why I chose what I chose. The code is on &lt;a href="https://github.com/SumonMSelim/molla" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 (this post):&lt;/strong&gt; the problem, the short code scheme, the read path, the edge, and the whole architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-2-never-serve-a-dead-link-5ggo"&gt;never serving a dead link&lt;/a&gt;, covering the cache record, versioned tombstones, stale fallback, and takedown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-3-writes-clicks-and-the-slow-path-3lj3"&gt;writes, clicks, and the slow path&lt;/a&gt;, covering idempotent creation, the click pipeline, abuse controls, and testing.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;What makes a URL shortener hard?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The product is small. Someone submits a long URL and gets back a seven-character code. Anyone who opens &lt;code&gt;https://mol.la/{code}&lt;/code&gt; is sent to the long URL. That is two endpoints and one table.&lt;/p&gt;

&lt;p&gt;The traffic shape is what makes it hard. A shortener is written once and read many times, and the reads are not spread evenly. A link posted in a popular place gets thousands of clicks a minute for an hour and then almost none. Every one of those clicks is a request that a human is waiting on, in the middle of opening a page. The redirect must be fast, it must be right, and it must stay right after the owner deletes the link or an operator takes it down.&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;Write volume&lt;/td&gt;
&lt;td&gt;100 million new links a month, about 39 a second on average and 154 a second at peak.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read volume&lt;/td&gt;
&lt;td&gt;100 reads for every write: about 3,900 redirects a second on average, 15,400 at peak.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect latency&lt;/td&gt;
&lt;td&gt;p99 under 50 ms measured at the edge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability&lt;/td&gt;
&lt;td&gt;99.9% for the redirect path, measured at CloudFront. A region-wide outage is an accepted risk in the first release.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention&lt;/td&gt;
&lt;td&gt;Links live for 5 years by default, then expire. Callers can choose a shorter lifetime, down to one minute.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom aliases&lt;/td&gt;
&lt;td&gt;A caller can pick a 3 to 32 character alias instead of a generated code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click stats&lt;/td&gt;
&lt;td&gt;A count per link, allowed to lag by minutes. Not billing-grade.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deletion and takedown&lt;/td&gt;
&lt;td&gt;A deleted or taken-down link must stop redirecting quickly, and must never come back.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;td&gt;The service never fetches, previews or probes a destination URL.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these define correctness: a deleted link must stay dead, and a new link must be visible on its first click. Part 2 is about those two. The rest of this post is about the shape of the system that serves the reads cheaply.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Where do 10 billion monthly redirects get answered?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The design assumes three cache tiers between a click and DynamoDB, and it assumes a hit rate for each. Figure 1 shows the model that every capacity and cost number in this series is built on.&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%2F0lpn5kw7gnqdz5v23na4.png" 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%2F0lpn5kw7gnqdz5v23na4.png" alt="Figure 1: Where redirects are answered under the design model. CloudFront sees all 10 billion. About half reach the redirect Lambda. Redis answers 95% of those, so about 250 million reads a month reach DynamoDB. These are assumptions to be replaced by metrics, not measurements." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things in that picture shaped the design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Viral links and the long tail behave differently.&lt;/strong&gt; CloudFront caches a redirect for only 5 seconds. For a link clicked a few times a day, that is always a miss. For a link clicked a thousand times a minute, it absorbs almost everything. The 50% edge hit rate is a blend of the two, and it is the number I trust least. It is also the number that matters least for correctness, because the edge cache is short by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database is the last resort, not the first.&lt;/strong&gt; With Redis in front of it, DynamoDB sees about one read in forty. A viral link is one partition key, and it cannot be sharded without changing the URL that people have already shared. The mitigation is that most of its traffic never reaches the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The per-request cost at the edge dominates the bill.&lt;/strong&gt; Every one of the 10 billion requests is a CloudFront viewer request, hit or miss. That is the expected shape of a 100:1 read-heavy service, and it is why the edge layer gets so much attention below.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Challenge 1: where does the short code come from?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the single decision that everything else depends on?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every link needs a code that is unique, short, hard to guess, and cheap to mint under concurrent writers. Whatever produces that code is on the write path of every request, so I chose it first.&lt;/p&gt;

&lt;p&gt;I compared 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. Hash the long URL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MD5 or SHA-256 of the URL, take the first 7 characters, check for a collision, retry&lt;/td&gt;
&lt;td&gt;Collisions become common at billions of rows, and every retry is a database round trip. Two people shortening the same URL also get the same code, which leaks who shortened what.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B. One incrementing counter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A database row holds the next integer; every create increments it&lt;/td&gt;
&lt;td&gt;A single hot writer that every request waits on. Codes are also sequential, so anyone can enumerate every link ever created.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C. Snowflake-style IDs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Timestamp, worker ID and a sequence number, packed into an integer and Base62-encoded&lt;/td&gt;
&lt;td&gt;Needs a stable, long-lived worker ID per generator. AWS Lambda recycles execution environments on its own schedule and gives them no durable identity. Two environments that mint the same worker ID can mint the same code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D. Leased blocks of integers, permuted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One DynamoDB counter item, advanced 1,000 at a time with an atomic &lt;code&gt;ADD&lt;/code&gt;. Each execution environment hands out integers from its block in memory. A keyed permutation hides the sequence before Base62 encoding.&lt;/td&gt;
&lt;td&gt;One write to the counter per 1,000 codes, about 0.15 writes a second at peak. No coordination between environments and no worker identity needed. A wasted partial block after a cold start costs nothing but ID space, of which there are 3.5 trillion.&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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkhp10gwuvxv0rqgpyusy.png" 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%2Fkhp10gwuvxv0rqgpyusy.png" alt="Figure 2: Snowflake assumes each generator has a durable worker ID, which Lambda cannot promise. Block leasing needs only one atomic counter update per block, and the permutation hides the order from clients." width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I chose D.&lt;/strong&gt; The deciding argument was Lambda itself. Snowflake is the usual production recommendation, and it is a good one on a fleet of long-lived servers. On a fleet of ephemeral functions it reintroduces the collision risk it was designed to remove. Block leasing needs only one atomic increment, which DynamoDB's &lt;code&gt;UpdateItem ADD&lt;/code&gt; provides without a read-modify-write race.&lt;/p&gt;

&lt;p&gt;Three details make this safe and unguessable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The permutation is a six-round Feistel network&lt;/strong&gt; over 42 bits, using HMAC-SHA-256 from the Go standard library as the round function, with the key held in an encrypted parameter. Values that land outside the Base62 domain are cycle-walked (permuted again until they land inside). The mapping is a bijection, so two different integers can never produce the same code, and the inverse exists for anyone holding the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seven Base62 characters&lt;/strong&gt; give 62^7, about 3.5 trillion codes. At 1.2 billion links a year the space lasts about 2,900 years. Base62 rather than Base64 because &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; are not safe in a URL without escaping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generated code is written with the same conditional put as a custom alias.&lt;/strong&gt; If a caller once chose the alias &lt;code&gt;aB3xK9c&lt;/code&gt; by hand, the generated code that happens to equal it fails its condition and the allocator takes the next integer. That retry is bounded to the handful of aliases that ever collide, not a search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The raw integer also reserves 3 high bits for a region ID, so a second region could mint codes without a cross-region coordinator. The first release uses region 0.&lt;/p&gt;

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

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

&lt;p&gt;The first design I sketched did the obvious thing: look the code up, increment a click counter on the same row, return the redirect. Under a burst, the counter increment makes the hottest row in the table also the most-written row in the table. That is the classic failure of this kind of system, and it is the one the reference literature warns about most loudly.&lt;/p&gt;

&lt;p&gt;So the redirect is split into a &lt;strong&gt;hot path&lt;/strong&gt; (what the click waits for) and a &lt;strong&gt;slow path&lt;/strong&gt; (what happens 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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ihkpyzic5uy74ybnjhp.png" 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%2F1ihkpyzic5uy74ybnjhp.png" alt="Figure 3: The hot path is one Redis read, one DynamoDB read only on a miss, and one click item written after the 302. Click counting happens on the slow path, fed by the table's stream." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the hot path, &lt;code&gt;GET /{code}&lt;/code&gt; does at most three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read Redis&lt;/strong&gt; for &lt;code&gt;url:{code}&lt;/code&gt;. On a fresh active record, that is the whole lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read DynamoDB&lt;/strong&gt; with a consistent &lt;code&gt;GetItem&lt;/code&gt;, only on a miss or when the cached record is stale. The result populates Redis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write one click item&lt;/strong&gt; to a separate &lt;code&gt;Clicks&lt;/code&gt; table, after the 302 has been written, with a 500 ms budget. If the write fails, the click is logged and lost. The redirect is never delayed or changed by it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The link item is deliberately never written on a click. An aggregation Lambda reads the click items from the table's stream in batches and increments a separate stats table a few seconds later. Part 3 covers that path, and the four ways of moving clicks that were compared before settling on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why 302 and not 301.&lt;/strong&gt; A 301 tells the browser the redirect is permanent, and browsers cache it indefinitely. That kills click counting, and it makes expiry, deletion and takedown impossible for anyone who has already clicked. A 302 brings the client back on almost every click. The cost of that round trip is paid by the cache tiers, not by weakening the redirect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching a 302 anyway.&lt;/strong&gt; The redirect carries &lt;code&gt;Cache-Control: public, max-age=5&lt;/code&gt;. That looks like a contradiction, but it is not. A 302 tells the browser not to assume the destination is permanent. It does not forbid an intermediary from caching it for exactly as long as the header says. Five seconds at the edge absorbs a viral spike without every click reaching Lambda, and it bounds how long a deleted link can be served after takedown to five seconds. Not-found and error responses carry &lt;code&gt;no-store&lt;/code&gt;, so a custom alias created a second after a miss is visible at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 3: three cache tiers, and why Redis over DAX
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why not the managed cache that sits in front of DynamoDB?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB Accelerator (DAX) is the obvious cache for a DynamoDB-backed read path. It was rejected because the redirect needs more than a cached row. Part 2 explains the cache record in full, but the short version is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;DAX&lt;/th&gt;
&lt;th&gt;Redis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An explicit "deleted" state that carries no destination&lt;/td&gt;
&lt;td&gt;No. DAX caches items; a deleted item is a miss, and a miss goes back to the table.&lt;/td&gt;
&lt;td&gt;Yes. A tombstone record with its own lifetime.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version comparison on write, so an old populate cannot overwrite a newer tombstone&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, in one atomic Lua script.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A bounded stale window for use only when DynamoDB is failing&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes. &lt;code&gt;fresh_until&lt;/code&gt; and &lt;code&gt;stale_until&lt;/code&gt; are part of the record.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider neutrality&lt;/td&gt;
&lt;td&gt;AWS only&lt;/td&gt;
&lt;td&gt;Any Redis. The same adapter runs against a local Redis in development.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the tiers are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1, CloudFront.&lt;/strong&gt; Under 5 ms. Caches a successful 302 for 5 seconds and nothing else. No &lt;code&gt;stale-while-revalidate&lt;/code&gt;, because that would extend takedown propagation past the 5-second window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2, ElastiCache for Redis.&lt;/strong&gt; Under 10 ms. Holds active records and deletion tombstones. Sized for the hot 20% of the daily working set, about 1 GB at the design targets. The Redis client has 150 ms dial, read and write timeouts and retries disabled, so a Redis failover degrades to a DynamoDB read instead of a hung invocation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3, DynamoDB.&lt;/strong&gt; Only on a double miss or a revalidation. Reads are strongly consistent, so a newly created link is visible on its first click rather than after DynamoDB's eventual-consistency window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unknown codes are cached nowhere. Negative caching was rejected because any valid alias can be created after an earlier miss, and a cached "missing" sentinel would hide it. A Bloom filter was rejected too: at 6 billion codes and a 1% false-positive rate it needs roughly a 7 GB bitset plus a correctness-sensitive update pipeline, to save reads that strict code validation and the edge rate limits already bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 4: the edge
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why two edge providers, and how does nothing get around them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The redirect endpoint is public and unauthenticated, so it needs a source rate control that costs less than the abuse it stops. The original design put an AWS WAF web ACL on the CloudFront distribution. At the design volume that is 10 billion requests evaluated a month, about $6,000 in WAF request charges alone. For a public shortener that is the wrong trade.&lt;/p&gt;

&lt;p&gt;The deployed design puts &lt;strong&gt;Cloudflare in front of CloudFront&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; holds the DNS zone, terminates TLS in Full (strict) mode against the ACM certificate on CloudFront, runs the firewall, and rate-limits &lt;code&gt;POST /api/v1/links&lt;/code&gt; per client IP. It also sets the real client IP in &lt;code&gt;CF-Connecting-IP&lt;/code&gt; on every request, which the origin can trust because Cloudflare overwrites any value the client sends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; routes by path. &lt;code&gt;/api/*&lt;/code&gt; goes to API Gateway with caching disabled. &lt;code&gt;/app/*&lt;/code&gt; and the root path go to a private S3 bucket through Origin Access Control and serve the static single-page app. Everything else, which is any bare single-segment path, goes to the redirect origin with the 5-second cache policy. The page and the API share one hostname, so the browser never sends a CORS preflight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The redirect origin is a Lambda Function URL, not API Gateway.&lt;/strong&gt; API Gateway would add about $1 per million requests on the read path, roughly $5,000 a month at 5 billion origin requests, for routing and usage plans that the redirect does not use. The Function URL costs nothing beyond the Lambda invocation. It uses IAM authorization, and CloudFront signs every origin request with Origin Access Control, so CloudFront is the only caller that can reach it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A second edge layer creates a new hole: the &lt;code&gt;*.cloudfront.net&lt;/code&gt; hostname is reachable by anyone, and a request sent straight to it never passes Cloudflare's firewall. Figure 4 shows how that hole is closed.&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%2Fgsmpdohw76ip4av8itea.png" 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%2Fgsmpdohw76ip4av8itea.png" alt="Figure 4: Cloudflare adds X-Origin-Verify to every request it forwards. A CloudFront viewer-request function rejects anything without the right value with a 403, before any origin is contacted, and strips the header so no origin ever sees it." width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Cloudflare Transform Rule stamps &lt;code&gt;X-Origin-Verify&lt;/code&gt; with a secret on every forwarded request. A CloudFront function runs on every viewer request, compares the header, returns &lt;code&gt;403&lt;/code&gt; with &lt;code&gt;no-store&lt;/code&gt; on a mismatch, and deletes the header before forwarding. The secret is generated by Terraform on the first apply and stored in the state. Both the rule and the function update in one plan, so rotating it is one apply and a few seconds of 403s while the function propagates.&lt;/p&gt;

&lt;p&gt;Two more edge details are worth knowing. CloudFront's response headers policy sends a strict Content Security Policy as a header, because browsers ignore &lt;code&gt;frame-ancestors&lt;/code&gt; in a meta tag, and it strips the S3 encryption headers that would otherwise reveal the AWS account ID and KMS key ARN to every viewer. And Cloudflare's bot protections are pinned off in Terraform, because the JavaScript detections inject an inline script that the site's CSP blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 5: compute for a serverless read path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why Lambda, and what does a VPC-attached function cost?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I compared three compute options for the redirect.&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;A fixed fleet behind a load balancer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Predictable and fast, but it means paying for idle capacity all month for a service whose traffic arrives in spikes, plus the operations of a fleet: patching, scaling policies, and a load balancer with its own hourly charge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Containers on Fargate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scales, but slower than Lambda to react to a viral spike, and the load balancer cost is the same.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lambda (chosen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scales to a spike in seconds, costs nothing at idle, and the Go binary on arm64 starts in about 100 to 300 ms cold. The p99 target rules out cold starts during ramps, so the redirect function is built to run with provisioned concurrency, sized from observed concurrency (peak QPS times p50 duration, about 160 at the design targets).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reaching Redis puts the redirect function inside a VPC, in two private subnets across two Availability Zones. The write function stays outside the VPC, because its normal request path never touches Redis. Consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No NAT gateway and no interface endpoints.&lt;/strong&gt; No function calls the public internet. Every dependency the redirect function has outside the VPC is DynamoDB, reached through a gateway VPC endpoint, which has no hourly charge. That was not true of the first deployed version, which published clicks to Kinesis through an interface endpoint at about $7.30 per AZ-month. Part 3 explains the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One connection per execution environment.&lt;/strong&gt; The Redis and SDK clients are created once at cold start and reused across invocations. Provisioned concurrency keeps a warm pool of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two roles, two functions.&lt;/strong&gt; The redirect function can read links, write click items and reach Redis. It cannot create or delete anything. The API function can create links and invoke cache invalidation. It cannot reach Redis directly or write clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every function is Go, compiled for &lt;code&gt;provided.al2023&lt;/code&gt; on arm64, at 128 MB. The redirect function has a 3-second timeout, and every dependency call inside it has a shorter budget: 150 ms for Redis, 1 second for the DynamoDB link read including the SDK's three attempts, 500 ms for the click write. A hung dependency therefore leaves room to fall back or fail fast, never to time out the whole invocation.&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%2Fwww.sumonselim.com%2Fimages%2Farticles%2Furl-shortener%2Farchitecture.png" 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%2Fwww.sumonselim.com%2Fimages%2Farticles%2Furl-shortener%2Farchitecture.png" alt="Figure 5: The full architecture as deployed. Cloudflare and CloudFront at the edge; a redirect Lambda in private subnets with Redis and VPC endpoints; API Gateway and an API Lambda for writes; a DynamoDB Clicks table, its stream and an aggregation Lambda on the slow path." width="800" height="570"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;The browser resolves &lt;code&gt;mol.la&lt;/code&gt; to &lt;strong&gt;Cloudflare&lt;/strong&gt;, which terminates TLS, runs the firewall, stamps the origin secret, and forwards to &lt;strong&gt;CloudFront&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;CloudFront function&lt;/strong&gt; checks the secret and strips it. On a cache hit, CloudFront answers the 302 itself.&lt;/li&gt;
&lt;li&gt;On a miss, CloudFront calls the &lt;strong&gt;redirect Lambda&lt;/strong&gt; through its Function URL, signed with Origin Access Control.&lt;/li&gt;
&lt;li&gt;The function reads &lt;strong&gt;Redis&lt;/strong&gt;. A fresh active record answers the click.&lt;/li&gt;
&lt;li&gt;On a Redis miss or a stale record, it reads &lt;strong&gt;DynamoDB&lt;/strong&gt; through the gateway endpoint and populates Redis.&lt;/li&gt;
&lt;li&gt;After writing the 302, it puts one item into the &lt;strong&gt;Clicks&lt;/strong&gt; table through the same gateway endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A link is created like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /api/v1/links&lt;/code&gt; goes through CloudFront to &lt;strong&gt;API Gateway&lt;/strong&gt; and the &lt;strong&gt;API Lambda&lt;/strong&gt;, outside the VPC.&lt;/li&gt;
&lt;li&gt;The function validates the request, leases an integer from the &lt;strong&gt;Counters&lt;/strong&gt; item if no alias was given, and writes the &lt;strong&gt;Links&lt;/strong&gt; item and the optional &lt;strong&gt;Idempotency&lt;/strong&gt; item in one DynamoDB transaction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the slow path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;strong&gt;aggregation Lambda&lt;/strong&gt; consumes the Clicks table's &lt;strong&gt;DynamoDB Stream&lt;/strong&gt; in batches and increments &lt;strong&gt;LinkStats&lt;/strong&gt; once per code per batch. Raw click items expire from the table after 90 days.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;operator takedown&lt;/strong&gt; soft-deletes the item and invokes the &lt;strong&gt;invalidation Lambda&lt;/strong&gt;, which writes a versioned tombstone to Redis. The cached 302 at the edge ages out on its own within 5 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; deploys by assuming an IAM role through OIDC, on a version tag only. There are no long-lived AWS keys anywhere in the pipeline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Alarms&lt;/strong&gt; cover the things that should wake someone up: CloudFront 5xx rate and origin p99 latency against the 50 ms target, API Gateway 5xx and p99 latency, Lambda errors and throttles on every function, Redis engine CPU and evictions, DynamoDB throttles on every table, the aggregation function's iterator age, its dead-letter queue, and any failed cache invalidation, which is owned by security rather than platform because a missing tombstone means a taken-down link is still live. Alarms go to an SNS topic and from there to email. A budget with a monthly limit sits next to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it would cost at the target
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does a 100:1 read-heavy service cost at 10 billion reads a month?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are list-price arithmetic at the design targets, not a bill. The deployed system runs at a tiny fraction of this.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line item&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;th&gt;Approx. per month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront&lt;/td&gt;
&lt;td&gt;10B requests, about 5 TB egress&lt;/td&gt;
&lt;td&gt;~$7,900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;~5B redirect invocations plus 100M writes, 128 MB, arm64&lt;/td&gt;
&lt;td&gt;~$1,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda provisioned concurrency&lt;/td&gt;
&lt;td&gt;~160 concurrent, redirect function only&lt;/td&gt;
&lt;td&gt;~$450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ElastiCache for Redis&lt;/td&gt;
&lt;td&gt;cache.r7g.large, primary plus replica&lt;/td&gt;
&lt;td&gt;~$400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway (write path only)&lt;/td&gt;
&lt;td&gt;100M requests&lt;/td&gt;
&lt;td&gt;~$350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB, links&lt;/td&gt;
&lt;td&gt;100M writes plus ~250M on-demand reads&lt;/td&gt;
&lt;td&gt;~$200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB, clicks&lt;/td&gt;
&lt;td&gt;5B on-demand writes, one per redirect that reaches Lambda&lt;/td&gt;
&lt;td&gt;~$6,250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch, Function URL, gateway endpoint&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;~$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;Free plan&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$16,750&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CloudFront request pricing dominates because every redirect is a viewer request whether it hits or misses. Two lines moved since the original design. AWS WAF at $0.60 per million requests, about $6,000, disappeared when the firewall moved to Cloudflare. And the click line grew from about $250 for Kinesis to about $6,250 for one DynamoDB write per click, because the deployed system chose the pipeline with no fixed monthly cost over the one with the lowest cost per event. At hobby scale that is the right trade by a wide margin, and Part 3 shows the arithmetic in both directions.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://dev.to/sumonselim/designing-a-url-shortener-in-aws-part-2-never-serve-a-dead-link-5ggo"&gt;Part 2&lt;/a&gt; looks inside the cache. It covers what a cache record holds and why it has three timestamps, how a versioned tombstone survives a late writer, when a stale record may be served and when it must not, and how a takedown propagates through DynamoDB, Redis and the edge in that order.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>go</category>
    </item>
    <item>
      <title>Scholia: an AI study partner that answers from course knowledgebase</title>
      <dc:creator>Muhammad Sumon Molla Selim</dc:creator>
      <pubDate>Sat, 03 Oct 2026 21:20:01 +0000</pubDate>
      <link>https://dev.to/sumonselim/scholia-an-ai-study-partner-that-answers-from-course-knowledgebase-5o5</link>
      <guid>https://dev.to/sumonselim/scholia-an-ai-study-partner-that-answers-from-course-knowledgebase-5o5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge&lt;/a&gt;: Path One, Ship an Agent That Queries Real Content.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Scholia is a study partner for one course. You ask it something, and it answers from that course's own material: the lecture recordings, the slide decks, the textbook, the problem sets and their rubrics, and your own graded work. Every claim comes with a chip you can click. A lecture chip opens the YouTube recording at that second. A slide chip names the deck and slide number. A book chip names the chapter and page.&lt;/p&gt;

&lt;p&gt;It has five modes, and each one only works because the content is structured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;What in the schema makes it possible&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Study&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;An explanation in the course's own terms, with where to see it again&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lecture.segments[]&lt;/code&gt; carry &lt;code&gt;startSec&lt;/code&gt;; &lt;code&gt;slideDeck.slides[]&lt;/code&gt; carry &lt;code&gt;number&lt;/code&gt;; &lt;code&gt;bookChapter.sections[]&lt;/code&gt; carry &lt;code&gt;pageStart&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assignment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Guidance through a problem set, step by step, under its rubric, without the solution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;assignment.rubric[]&lt;/code&gt; criteria each reference &lt;code&gt;topics[]&lt;/code&gt;, and assignments reference &lt;code&gt;relatedLectures[]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Improve&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where you are losing marks and exactly what to rewatch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;submission.feedback[].criterionKey&lt;/code&gt; points at a rubric criterion, which points at topics, which are referenced by segments, slides and sections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Revise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A revision sheet per topic, every bullet cited&lt;/td&gt;
&lt;td&gt;topic references on every segment, slide and book section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mock exam&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Questions one at a time, sampled by exam weight, graded out of 10 with citations&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;examScope.topics[]&lt;/code&gt; carry a &lt;code&gt;weight&lt;/code&gt; and point at topics and learning objectives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the course does not cover something, Scholia searches a short allowlist of trusted sites (docs.python.org, ocw.mit.edu, Wikipedia and a few universities). Those results never mix with course citations. They appear in an amber "Outside course material" section, and a useful one can be saved back to Sanity as a &lt;code&gt;webReference&lt;/code&gt; for course staff to review.&lt;/p&gt;

&lt;p&gt;There are three demo courses, all from MIT OpenCourseWare (CC BY-NC-SA 4.0), each paired with an openly licensed textbook:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;6.0001&lt;/strong&gt; Introduction to Computer Science and Programming in Python, with Allen Downey's &lt;em&gt;Think Python 2e&lt;/em&gt; (CC BY-NC 3.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6.0002&lt;/strong&gt; Introduction to Computational Thinking and Data Science, with Allen Downey's &lt;em&gt;Think Stats 2e&lt;/em&gt; (CC BY-NC-SA 4.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6.006&lt;/strong&gt; Introduction to Algorithms, with Pat Morin's &lt;em&gt;Open Data Structures&lt;/em&gt; (CC BY 2.5 Canada)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The graded submissions and their grader feedback are fictional, written so Improve mode has something to work with. The examples below use 6.0001.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it answers that keyword search could not
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;"Where am I losing marks, and what should I rewatch?"&lt;/strong&gt; None of those words appears in the course material. To answer, the agent has to walk a chain: each piece of grader feedback names a rubric criterion by key, each criterion is tagged with topics, and each topic is referenced by specific lecture segments, slides and book sections. Scholia does the walk in GROQ, deterministically, then asks for the exact locators:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recursion&lt;/strong&gt; (lost 3 points, 1 major). Base case handling in recursive permutations. Rewatch: understanding base cases and recursive unwinding &lt;code&gt;Lecture 6 · 13:17&lt;/code&gt;; slides on recursive multiplication and factorial &lt;code&gt;Lecture 6 · slide 9&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dictionaries, tuples, lists and mutability&lt;/strong&gt; (lost 2 points each). Mutating the input hand instead of cloning it. Rewatch: cloning lists to avoid aliasing side effects &lt;code&gt;Lecture 5 · 35:08&lt;/code&gt;; list cloning with slicing &lt;code&gt;Lecture 5 · slide 20&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bars in the side panel (points lost per topic, across all four submissions) come from the same query, not from the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What is the complexity of &lt;code&gt;bisect_search1&lt;/code&gt;?"&lt;/strong&gt; A keyword search finds slide 14 of lecture 11, and that slide says two things. It first multiplies O(log n) calls by O(n) copying per call and writes O(n log n). A few lines later it says "if we are really careful", the copied lengths halve each time, so the total is O(n). Scholia gives both figures, explains why the careful count wins, answers O(n), and cites the slide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What is the walrus operator, and does this course cover it?"&lt;/strong&gt; The course predates Python 3.8. Scholia says so in one sentence, then answers under "Outside course material" from docs.python.org, shown apart from the course sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://scholia.mol.la" rel="noopener noreferrer"&gt;https://scholia.mol.la&lt;/a&gt; (no login). Studio is embedded at &lt;a href="https://scholia.mol.la/studio" rel="noopener noreferrer"&gt;https://scholia.mol.la/studio&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Try these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Study: &lt;em&gt;Why does recursion need a base case?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Improve: &lt;em&gt;Where am I losing marks, and what should I rewatch?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Assignment: &lt;em&gt;Help me start problem set 3 without giving me the answer.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Mock exam: &lt;em&gt;Start a mock exam.&lt;/em&gt; Then answer the first question.&lt;/li&gt;
&lt;li&gt;Study: &lt;em&gt;What is the walrus operator?&lt;/em&gt; (watch it leave the course)&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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31irs63mju7ytqs7cwkw.png" 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%2F31irs63mju7ytqs7cwkw.png" alt="Study mode: the answer cites Lecture 6 at 4:53, slide 6 and Lecture 6 at 19:18 inline, and the panel lists the same sources" width="800" height="514"&gt;&lt;/a&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%2Fvqdkyvoshczy03cy58fo.png" 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%2Fvqdkyvoshczy03cy58fo.png" alt="A question the course does not cover: one sentence saying so, then an answer from docs.python.org under Outside course material" width="799" height="571"&gt;&lt;/a&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%2Fr1o2n3k19y4vp5sn2lmt.png" 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%2Fr1o2n3k19y4vp5sn2lmt.png" alt="Dark theme" width="800" height="514"&gt;&lt;/a&gt;&lt;br&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%2Fzmgoivt77s9m57n45uhq.png" 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%2Fzmgoivt77s9m57n45uhq.png" alt="Phone layout" width="390" height="844"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/SumonMSelim" rel="noopener noreferrer"&gt;
        SumonMSelim
      &lt;/a&gt; / &lt;a href="https://github.com/SumonMSelim/scholia" rel="noopener noreferrer"&gt;
        scholia
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Scholia&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A study partner that only works because the course content is structured.&lt;/p&gt;
&lt;p&gt;Scholia turns everything a course throws at a student (lecture recordings, slide decks, a textbook
assignments, graded submissions) into typed Sanity documents, builds a Sanity Knowledge Base on top, and
puts an agent in front of it. Every answer cites the exact lecture second, slide number or book page.&lt;/p&gt;
&lt;p&gt;Built for the DEV x Sanity Challenge (Path One). Three demo courses from MIT OpenCourseWare (CC BY-NC-SA 4.0)
each paired with an openly licensed textbook:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;6.0001 Introduction to Computer Science and Programming in Python, with Think Python 2e (CC BY-NC 3.0)&lt;/li&gt;
&lt;li&gt;6.0002 Introduction to Computational Thinking and Data Science, with Think Stats 2e (CC BY-NC-SA 4.0)&lt;/li&gt;
&lt;li&gt;6.006 Introduction to Algorithms, with Open Data Structures (CC BY 2.5 CA)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The student's submissions and grader feedback are fictional.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sanity project &lt;code&gt;dqd1lxzm&lt;/code&gt;, dataset &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;Live: &lt;a href="https://scholia.mol.la" rel="nofollow noopener noreferrer"&gt;https://scholia.mol.la&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Modes&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/SumonMSelim/scholia" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Next.js 16 with an embedded Sanity Studio 6, the Vercel AI SDK 7 with its MCP client, Amazon Bedrock (Nova 2 Lite), Tavily for the web fallback, and AWS CDK for the deploy. Everything, including the CDK deploy and the headless browser checks, runs in Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Content model
&lt;/h3&gt;

&lt;p&gt;Eighteen schema types. The documents are &lt;code&gt;course&lt;/code&gt;, &lt;code&gt;topic&lt;/code&gt;, &lt;code&gt;learningObjective&lt;/code&gt;, &lt;code&gt;lecture&lt;/code&gt;, &lt;code&gt;slideDeck&lt;/code&gt;, &lt;code&gt;book&lt;/code&gt;, &lt;code&gt;bookChapter&lt;/code&gt;, &lt;code&gt;assignment&lt;/code&gt;, &lt;code&gt;submission&lt;/code&gt;, &lt;code&gt;examScope&lt;/code&gt; and &lt;code&gt;webReference&lt;/code&gt;. The objects inside them are &lt;code&gt;segment&lt;/code&gt;, &lt;code&gt;slide&lt;/code&gt;, &lt;code&gt;bookSection&lt;/code&gt;, &lt;code&gt;rubricCriterion&lt;/code&gt;, &lt;code&gt;feedbackItem&lt;/code&gt;, &lt;code&gt;scopedTopic&lt;/code&gt; and &lt;code&gt;sourceInfo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two decisions shaped everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locators live inside the documents.&lt;/strong&gt; A lecture is one document with an array of segments. Each segment is two to four minutes of the caption track, with &lt;code&gt;startSec&lt;/code&gt;, &lt;code&gt;endSec&lt;/code&gt;, text and topic references. Slides and book sections work the same way with &lt;code&gt;number&lt;/code&gt; and &lt;code&gt;pageStart&lt;/code&gt;. The Knowledge Base can therefore summarise a whole lecture, while the exact second is still one GROQ projection away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Topics are the joins.&lt;/strong&gt; Segments, slides, book sections, rubric criteria and exam scope entries all reference the same twelve &lt;code&gt;topic&lt;/code&gt; documents. Feedback references a rubric criterion by its &lt;code&gt;_key&lt;/code&gt;. That is what turns "you mutated the input" into "rewatch lecture 5 at 35:08".&lt;/p&gt;

&lt;p&gt;The ingest pipeline is plain TypeScript. It parses the OCW caption files (VTT) into time windows, extracts slide and book text per page from the PDFs with &lt;code&gt;unpdf&lt;/code&gt;, merges hand-written metadata (topics, rubrics, exam weights, the fictional submissions), and writes each course's documents (77, 74 and 81) in one transaction with deterministic IDs. A course is one seed file of hand-authored structure; adding 6.0002 and 6.006 meant writing two seed files, not changing the pipeline, apart from optional settings for one book's page headers and one lecture without notes. One transaction mattered, because the documents reference each other and Sanity rejects a reference to a document that does not exist yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Knowledge Base and Context MCP
&lt;/h3&gt;

&lt;p&gt;One Knowledge Base serves all three courses through one Context MCP endpoint. The plan I am on indexes at most 150 documents per organisation, and the three courses have 232, so the Knowledge Base gets the 106 documents that explain: courses, topics, lectures with their full transcripts, assignments with rubrics, exam scopes and books. Everything else is still one tool away: slide decks and book chapters through &lt;code&gt;lookup_source&lt;/code&gt;, graded submissions through &lt;code&gt;weak_topics&lt;/code&gt;, and learning objectives through &lt;code&gt;exam_plan&lt;/code&gt;, all of which query the dataset directly. Two things keep answers inside the selected course. The agent is told to use only Knowledge Base entries about that course, and every source lookup is filtered by course in GROQ, so a citation can never point into another course.&lt;/p&gt;

&lt;p&gt;The agent connects to a Context MCP endpoint in Knowledge Base mode, with an organisation token, through &lt;code&gt;@ai-sdk/mcp&lt;/code&gt;. It uses the three tools the endpoint exposes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;initial_context&lt;/code&gt;, once per conversation, for the Knowledge Base ID and outline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;knowledge_base_search&lt;/code&gt; with two to four keywords.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;knowledge_base_read&lt;/code&gt; on the best matching entries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Knowledge Base explains. It does not reliably give a second or a page number, so I gave the agent four tools of its own next to the MCP ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lookup_source&lt;/code&gt;&lt;/strong&gt; runs GROQ over the typed arrays. It takes keywords plus an optional lecture number or topic slug, and returns segments, slides and book sections with ready-made cite strings such as &lt;code&gt;[lecture 6 @ 4:53]&lt;/code&gt;. GROQ's &lt;code&gt;match&lt;/code&gt; is prefix based, so I stem the keywords ("aliasing" becomes "alias") and rank results by how many keywords each one hits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;weak_topics&lt;/code&gt;&lt;/strong&gt; joins feedback to rubric criteria to topics and totals the points lost. The model reads the totals and does not compute them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exam_plan&lt;/code&gt;&lt;/strong&gt; samples topics by exam weight, without replacement and with a seed, and attaches the learning objectives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;web_search&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;save_web_reference&lt;/code&gt;&lt;/strong&gt; handle the fallback. Context MCP is read-only, so saving goes through &lt;code&gt;@sanity/client&lt;/code&gt; with a write token and lands as &lt;code&gt;status: pending&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Citations the UI can check
&lt;/h3&gt;

&lt;p&gt;The agent writes citations in a fixed syntax: &lt;code&gt;[lecture 6 @ 12:40]&lt;/code&gt;, &lt;code&gt;[slides 4 #5]&lt;/code&gt;, &lt;code&gt;[book ch.11 p.106]&lt;/code&gt;, &lt;code&gt;[assignment 3: hands]&lt;/code&gt;, &lt;code&gt;[submission ps3]&lt;/code&gt;. The UI parses them and resolves each one against the course's sources (video URL plus &lt;code&gt;&amp;amp;t=&lt;/code&gt; seconds, slide deck link, book link). It also collects every cite string the tools returned in that turn. Any citation the model wrote that no tool returned gets an "unverified" badge. A malformed one, such as &lt;code&gt;[lecture 12 @ ?]&lt;/code&gt;, is not parsed at all and stays plain text, so it never becomes a link to the wrong place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploy
&lt;/h3&gt;

&lt;p&gt;A container Lambda running Next.js behind the Lambda Web Adapter, which streams responses. The Function URL requires IAM auth and only CloudFront can call it, through origin access control. A CloudFront Function returns 403 unless the Host header is &lt;code&gt;scholia.mol.la&lt;/code&gt;, so the &lt;code&gt;cloudfront.net&lt;/code&gt; and Lambda URLs both refuse direct access. Idle cost is close to zero.&lt;/p&gt;

&lt;p&gt;Two things cost me time here. Function URLs created after October 2025 need &lt;code&gt;lambda:InvokeFunction&lt;/code&gt; for CloudFront as well as &lt;code&gt;lambda:InvokeFunctionUrl&lt;/code&gt;, otherwise every request is a 403. And with origin access control, a POST body has to arrive with its SHA-256 in &lt;code&gt;x-amz-content-sha256&lt;/code&gt;, so the chat client hashes each request body in the browser before sending it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest notes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model is the weak link.&lt;/strong&gt; My AWS credits only cover first-party models, so the agent runs on Amazon Nova 2 Lite. Nova Pro dropped the citation brackets, and Nova Premier had reached end of life. Early versions invented timestamps, wrote full solutions in Assignment mode, and printed all the exam questions at once. Prompting helped, but the reliable fixes are in code: cite strings come from tools and the UI checks them, Assignment mode folds any code block longer than three lines behind a "try writing this step yourself first" toggle, and points lost and exam sampling are computed in GROQ and TypeScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fitting the plan cost me a conflict check.&lt;/strong&gt; When slide decks were in the Knowledge Base, its build flagged &lt;code&gt;bisect_search1&lt;/code&gt; as a conflict, and Scholia still answered O(n) without saying the slide also writes O(n log n). With slides out, nothing is flagged any more. Scholia now gives both figures anyway, because &lt;code&gt;lookup_source&lt;/code&gt; returns the whole slide, but it cites that one slide on nearly every line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A citation can point at the right source for the wrong claim.&lt;/strong&gt; Asked about hash collisions in 6.006, Scholia cited &lt;em&gt;Open Data Structures&lt;/em&gt; chapter 5 for how Python's &lt;code&gt;dict&lt;/code&gt; probes, which that book does not cover. The UI checks that a citation came from a tool, not that the source says what the sentence says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bug I found while writing this post.&lt;/strong&gt; On the live site, Improve mode once spent its whole step budget on eight lookups and ended without an answer. The agent now gets ten steps, and on the last one it is told to stop looking things up and answer from what it has. Simply removing the tools on that step looked cleaner, but the Bedrock provider then also drops every earlier tool result from the request, so the answer would have lost its sources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slide text is lossy.&lt;/strong&gt; Many OCW slides use two columns, and PDF text extraction interleaves them (see slide 14 above). Segments from the captions are much cleaner, which is one reason lecture timestamps are the most precise citations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;dqd1lxzm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public query example:&lt;/strong&gt; &lt;a href="https://dqd1lxzm.apicdn.sanity.io/v2026-10-01/data/query/production?query=*%5B_type%3D%3D%22lecture%22%5D%7Bnumber%2Ctitle%2C%22segments%22%3Acount(segments)%7D" rel="noopener noreferrer"&gt;https://dqd1lxzm.apicdn.sanity.io/v2026-10-01/data/query/production?query=*%5B_type%3D%3D%22lecture%22%5D%7Bnumber%2Ctitle%2C%22segments%22%3Acount(segments)%7D&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio:&lt;/strong&gt; &lt;a href="https://scholia.mol.la/studio" rel="noopener noreferrer"&gt;https://scholia.mol.la/studio&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <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 language proficiency test certification exam had 20,700 seats across five levels. When registration opened last year, millions of people tried to access and book. The previous system crashed under that load. It also sold the same seat twice. &lt;/p&gt;

&lt;p&gt;To make the system scalable, highly-available and reliable for this year's registration, I re-designed the system and 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 link, 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>
