# Running a URL Shortener on Cloudflare for $0 (Part 1): The Architecture

> Source: https://www.sumonselim.com/url-shortener-cloudflare-part1-the-architecture/
> Author: Muhammad Sumon Molla Selim
> Published: 2026-10-04
> Tag: System Design
> Summary: mol.la is an open-source URL shortener that runs on one Cloudflare Worker and one D1 database for $0 a month. Part 1 explains the requirements, which free-plan quota really limits the design, why KV, Queues and Durable Objects were left out, what every action costs in requests and rows written, and how the code keeps the platform at the edges.
>
> Series: Running a URL Shortener on Cloudflare for $0, part 1 of 4
> 1. The Architecture (this part)
> 2. [The Redirect at the Edge](https://www.sumonselim.com/url-shortener-cloudflare-part2-the-redirect-at-the-edge.md)
> 3. [Writes and Abuse on a Free Plan](https://www.sumonselim.com/url-shortener-cloudflare-part3-writes-and-abuse-on-a-free-plan.md)
> 4. [Operating It for Free](https://www.sumonselim.com/url-shortener-cloudflare-part4-operating-it-for-zero.md)

**TL;DR:** [mol.la](https://mol.la) is an open-source URL shortener. It runs on one Cloudflare Worker, one D1 database and the edge cache, and it costs $0 a month on the Workers Free plan. The whole design follows three rules.

First, **the redirect touches as little as possible**. A click is answered by the browser cache, then by the cache in the nearest Cloudflare data center, and only then by D1. The click is counted after the response, never inside it.

Second, **each free quota is a budget**. The question is not "what does this cost?" but "which daily quota does this use?" For mol.la the scarcest one is D1 rows written, not requests.

Third, **the core is pure code, and the platform stays at the edges**. Code generation, validation and the redirect decision import nothing from Cloudflare. Everything that touches D1 or the cache sits behind a small interface, so every handler can be tested in the real Workers runtime with no account.

## The problem

**What does a URL shortener have to do well?**

A URL shortener looks like a key-value lookup. The requirements make it harder:

| Requirement | What it means for the design |
| :-- | :-- |
| Short codes that cannot be guessed | 7 characters, not sequential, never reused |
| Custom aliases | A user may pick `mol.la/launch`, so codes and aliases share one namespace |
| Expiry | Every link has an end date, 5 years by default |
| Fast redirects everywhere | The common case must be answered near the user, without a database round trip |
| Removed links stop working quickly | A cache must not keep serving a link after it is removed |
| Click counts | Useful, but approximate is fine |
| A public API with no accounts | Abuse control has to work per client IP |
| $0 a month | Every component must fit a free plan |

The last row decides most of the others.

## The constraint

**What does the Workers Free plan give you?**

| Resource | Free per day | Note |
| :-- | :-- | :-- |
| Worker requests | 100,000 | Over the limit, the route returns error 1027 |
| CPU time | 10 ms per request | Waiting on I/O does not count |
| Static asset requests | Unlimited | Served before the Worker runs |
| D1 rows read | 5,000,000 | |
| D1 rows written | 100,000 | Each index touched by a write adds one more row |
| D1 storage | 500 MB per database | Up to 10 databases and 5 GB per account |
| KV writes | 1,000 | |
| Queues operations | 10,000 | About 3 per message |

All daily limits reset at 00:00 UTC. Since 1 September 2026, D1 queries fail when an account goes over its daily row limits.

This sets the failure mode. Too much traffic does not create a bill. It stops the service until midnight UTC. For a hobby project, I prefer an outage I can see to a bill I did not expect.

## Challenge 1: which products

**Why one Worker and one database, and not KV, Durable Objects or Queues?**

Each product was checked against the quota it would use:

- **KV as a redirect cache.** Every cache fill is a KV write, and the free plan has 1,000 writes a day. Clicks on 1,000 different codes would use up the quota.
- **Queues for clicks.** A message costs about three operations: write, read and delete. 10,000 operations is about 3,300 clicks a day, far below the Worker's 100,000 requests.
- **Durable Objects.** The free plan only offers SQLite-backed objects, with the same row limits as D1. They would add a second storage model and no extra quota.
- **The Cache API.** It has no quota of its own. A Worker can store and read responses in the cache of the data center that is serving the request. It is the redirect cache.
- **Static Assets.** The web UI is plain files. Requests that match a file are served before the Worker runs, for free.
- **D1.** One SQLite database holds links, idempotency records, the ID counter and click counts. Every lookup is by primary key.

What is left is one Worker that serves redirects, the JSON API and the UI shell. It has one D1 database, one cron trigger, and one rate-limit binding.

![mol.la on Cloudflare: one Worker, one D1 database and the edge cache](https://www.sumonselim.com/images/articles/url-shortener-cloudflare/architecture.svg "Figure 1: Every request reaches Cloudflare's edge. Static files are served without the Worker. Everything else runs in one Worker. It reads the edge cache first, then D1. The only scheduled job is a daily cleanup.")

## Challenge 2: where the budget goes

**What does one click cost?**

The question is not about dollars. It is about which quotas each action uses:

| Action | Worker requests | D1 rows written | D1 rows read |
| :-- | :-- | :-- | :-- |
| Redirect, edge cache hit | 1 | 1 (2 on a code's first click) | 0 |
| Redirect, cache miss | 1 | 1 (2 on a code's first click) | 1 |
| Create a link from the web UI | 1 | 6 | 0 |
| Lease a new block of 100 IDs | 0 | 1 | 0 |
| Stats API call for a link | 1 | 0 | 2 |
| Static page or asset | 0 | 0 | 0 |

Two things in this table shaped the design.

**An edge cache hit is not free.** The cache lives inside the Worker, so every hit still uses a request. The hit also writes a click to D1, so cached redirects still use writes. Requests and writes run out at about the same point: 100,000 clicks a day.

**Indexes cost writes.** D1 counts each index entry a write touches as another row written. A new link writes its row, the automatic index behind its text primary key, and its `purge_at` index entry: 3 rows. The web UI always sends an idempotency key, and that record costs 3 more. I measured these numbers against local D1. A create costs six times as much as a click. Clicks are still the bigger total, because there are far more of them.

The upgrade trigger is about 70,000 requests or 70,000 rows written in a day. Above that, the Workers Paid plan costs $5 a month. It turns the hard stop into per-use billing and includes 10 million requests and 50 million D1 rows written each month.

## Challenge 3: keep the platform at the edges

**How is the code laid out so the design can be tested?**

The Worker is one bundle under 100 KB, in four layers:

| Layer | Files | Depends on Cloudflare? |
| :-- | :-- | :-- |
| Core | `core/`: Base62, the code permutation, URL and alias validation, the redirect decision | No. Pure functions |
| Handlers | `handlers/`: redirect, create, stats | Only through interfaces passed in |
| Adapters | `store.ts` for D1, the purge client | Yes |
| Router | `index.ts`: matches paths and wires adapters into handlers | Yes |

A handler never reaches for a global. The redirect handler gets a link store, a stats store and a cache as arguments. The tests run inside workerd, the Workers runtime, with a local D1 and the Cache API, so the real SQL runs against SQLite in the same runtime as production.

Two things live in module memory, so they survive between requests in the same isolate: the imported HMAC key for code generation, and the current block of leased IDs. Both are cheap to lose. A new isolate imports the key again and leases a new block.

A test file of fixed vectors pins the code generator. For example, ID 0 with the test key must encode to `UIiAFaQ`. Any change to the permutation, the alphabet or the code length fails that test before it can change which code an ID maps to.

## What it gives up

**Which risks are accepted?**

- **A ceiling.** The free plan stops at about 100,000 redirects a day, or about 3 million a month.
- **One database location.** D1 has one primary. If it is down, redirects already in a data center's cache keep working for up to 60 seconds. Everything else returns 503. There is no automatic failover.
- **No load tests.** Any sustained load test would use up the day's quota.
- **Short staleness windows.** A removed link can still redirect for about a minute in some data centers. Part 2 explains why that is the chosen trade-off.
- **Approximate clicks.** A failed click write is logged, not retried.

Most of these are listed as accepted risks in the project's architecture review. At this traffic, closing them would cost more than the service is worth.

## What's next

Part 2 follows one click through the edge. It covers why the Cache API and not Workers Cache, why one response carries two different cache lifetimes, how a removed link stops working without a versioned cache, and how a click is counted after the redirect is sent.
