TL;DR: 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 one DynamoDB transaction. A retry with the same Idempotency-Key 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.
Clicks are written as items to a DynamoDB Clicks table after the redirect is written, and the table’s stream 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.
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.
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.
This is the last part of a three-part series. Part 1 covers the architecture and the edge. Part 2 covers how the cache stays correct.
The create path
What happens inside POST /api/v1/links?
The request is a JSON body with one required field, long_url, and two optional ones, alias and expires_in. The handler does the following, in order:
- Bound the body at 16 KiB before decoding. A URL is at most 2,048 characters, so a larger body is never legitimate.
- Validate the URL without a network call. It must parse, use
httporhttps, have a non-empty host, carry no userinfo and no control characters, and fit in 2,048 characters. Anything else is400 INVALID_URL. The scheme allowlist is what keepsjavascript:,data:andfile:out of aLocationheader. - Validate the alias, if given: 3 to 32 characters from letters, digits, hyphen and underscore, and not
apiorapp, which are routing prefixes at the edge. - Validate the lifetime: 60 seconds to 5 years, with 5 years as the default.
- Validate the idempotency key, if the header is present: 1 to 128 visible ASCII characters.
- Mint the code: the alias as given, or a leased integer permuted and encoded as described in Part 1.
- Write one transaction and map its outcome to a response.
The handler never fetches the target URL. No liveness check, no HEAD 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.
Challenge 1: one transaction, three outcomes
How does a retry get the same answer as the original?
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 Idempotency-Key header, and the interesting part is what it is scoped to and what it is compared against.
I compared three designs.
| Option | Problem |
|---|---|
| Store the key in the link item and query by it | 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. |
| Write the idempotency record first, then the link | 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. |
| One transaction over both items (chosen) | TransactWriteItems 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. |
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 {long_url, alias, expires_in} struct with defaults filled in, never over the raw JSON bytes, so whitespace and key order do not matter but a changed body does.
A cancelled transaction has to be classified, and a guess is not good enough:
- No idempotency key on the request: the only condition that can fail is the link’s. For an alias that is
409 ALIAS_TAKEN. 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. - Key given, record found with the same hash: a retry. Read the stored link and return the original
201. - Key given, record found with a different hash: the same key reused for a different request.
409 IDEMPOTENCY_CONFLICT. - Key given, no record: 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.
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.
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 {owner_id}#{key} with an empty owner, so that when authenticated ownership returns, the scope narrows to one caller without a data migration.
Challenge 2: clicks off the hot path
How do you count 10 billion clicks a month without a counter on the hot row?
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.
The first deployed version used the textbook shape: the redirect published each click to a Kinesis Data Streams stream, an aggregation Lambda consumed it, and Firehose 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 interface endpoint at about $7.30 per AZ-month just to reach it. About $36 a month of fixed cost to carry a few hundred clicks.
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.
| Option | Fixed cost per month | Cost at 5B clicks a month | What you get, and what you give up |
|---|---|---|---|
| Kinesis Data Streams plus Firehose archive | ~$36 (on-demand stream hours plus one interface endpoint) | ~$250 | 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. |
| SQS standard | ~$7 (interface endpoint; SQS has no gateway endpoint) | ~$2,000 (one request per click plus receives) | Simple and near-free per message, but a VPC function still needs an interface endpoint. One consumer only, so no archive. |
DynamoDB Clicks table plus DynamoDB Streams | $0 (gateway endpoint is free; Lambda reads from Streams are free) | ~$6,250 (one on-demand write per click) | Keeps the batch aggregator unchanged. The items are the archive, expiring by TTL. Highest cost per click by far. |
Direct UpdateItem ADD on LinkStats from the redirect | $0 | ~$6,250 (one write per click) | 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. |
I chose the DynamoDB table with Streams. 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.
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.
The details that matter:
- The item is small and private.
{event_id, short_code, occurred_at, source_ip_hash, ttl}. 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. - The write has a 500 ms budget and happens after the 302. 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.
- The stream carries inserts only. The event source mapping filters on
eventName = INSERT, so the TTL sweep’sREMOVErecords, which arrive on the same stream 90 days later, never invoke the function or count anything. The function checks the event name again anyway. - The aggregator folds a batch into one write per code. It reads up to 100 records or 5 seconds’ worth, groups them by the
short_codein each item’s image, and issuesUpdateItem ADD clicks :n SET last_click_atonce 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. - The event ID is the partition key 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.
Counts are approximate, and the API says so. 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.
Challenge 3: abuse controls without accounts
How do you protect a public API that has no callers to identify?
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.
| Layer | What it controls | Why it is there |
|---|---|---|
| Cloudflare rate limit | POST /api/v1/links per client IP: a handful of requests per 10-second window, then blocked for 10 seconds | 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. |
| Cloudflare WAF | Managed rules on every request | Drops known-bad traffic at the edge before it is billed anywhere else. |
| API Gateway stage throttle | Total requests per second and burst across all callers | A blunt aggregate ceiling that protects the Lambda concurrency pool and the DynamoDB counter. It is not per-IP. |
| Strict input validation | Code charset and length on every redirect, body size and URL rules on every create | Bounds what probing and junk can cost before any storage access. |
| Operator takedown | Soft delete plus tombstone, by an IAM-authenticated CLI with a mandatory reason | The response to a malicious destination. Identity comes from STS, never from a flag or a key. |
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.
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.
Challenge 4: a codebase that can be tested without a cloud
How was this checked before it was deployed?
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.
The code is split into three kinds of package:
coreholds the pure logic: Base62 encoding, the keyed permutation, validation, click aggregation, and the redirect decision. It imports nothing outside the standard library.platformdeclares 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.handlersimplements the HTTP contract as plainnet/httphandlers that depend only on the ports. They never import a Lambda type or an AWS SDK.
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.
Each layer answers a different question:
- Core tests are table-driven: known Base62 vectors, round-trips over a range of integers, the permutation’s bijectivity on a sampled range plus the boundaries
0and62^7 - 1, every validation edge, and every redirect branch. A benchmark covers permute-plus-encode, the allocator’s hot path. - Handler tests 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.
- Adapter tests 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.
- Infrastructure checks 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.
- The load envelope 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.
- An audit of the live stack 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.
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.
Lessons
What would I tell someone building this next?
- Decide the hot path first, and keep everything else out of it. 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.
- Check the assumptions of the standard answer against your runtime. 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.
- Give every cache record a version, and compare it in the same atomic step as the write. 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.
- Serve stale only inside a window you can defend, and never over a positive “no”. Availability during a database failure is worth 15 minutes of bounded staleness. It is not worth resurrecting a link that was taken down.
- Make the delete finish in the cache. 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.
- Never fetch what the user gave you. 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.
- Say what the numbers mean. 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.
- Structure the code so the cloud is optional. 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.
- Price the design at the scale you will actually run, not only the scale you designed for. 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.
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.