Let’s make a URL shortener using Cloudflare’s serverless tools and Rust! We’ll build a quick (more on that below), stable service that runs close to users.
The Main Parts
Cloudflare Workers
These are small programs that run on Cloudflare’s network of servers. They work like mini-computers spread across more than 200 cities around the world.
D1
This is Cloudflare’s SQLite database that runs on their servers. It’s great for storing our URL data because it:
- Keeps data safe and stable
- SQL query support
- Replication across regions
KV (Key-Value)
This is Cloudflare’s distributed key-value store. We will use it as a cache to:
- Lower database load
- Improve responses time
Starting the Project
Let’s make a new Rust worker project:
# Install cargo-generate if you don't have it*cargo binstall cargo-generate
# Create a new worker project*cargo generate cloudflare/workers-rs# Select templates/hello-world-http when prompted*
Add these packages to your Cargo.toml
:
[dependencies]worker = { version="0.5", features=["http", "d1"] }worker-macros = { version="0.5", features=["http"] }console_error_panic_hook = { version = "0.1" }serde = { version = "1.0", features = ["derive"] }serde_json = "1.0"
Database Initialization
Make a schema.sql file (this is our “migration” file):
CREATE TABLE urls ( short_code TEXT PRIMARY KEY, long_url TEXT NOT NULL);
# Create D1 databasenpx wrangler d1 create shortner-db
# Set up database tablesnpx wrangler d1 execute shortner-db --file=./schema.sql --remote
Worker Code
Our worker does two things:
- Finds short URLs
- Sends users to the full URL
use serde::{Deserialize, Serialize};use worker::*;
#[derive(Debug, Serialize, Deserialize)]struct UrlEntry { short_code: String, long_url: String,}
#[event(fetch)]async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> { console_error_panic_hook::set_once();
let router = Router::new(); router .get_async("/ping", |_, _ctx| async move { Response::ok("pong") }) .get_async("/c/:code", |_req, ctx| async move { let code = match ctx.param("code") { Some(code) => code, None => return Response::error("missing-code", 400), };
let kv = ctx.kv("URL_CACHE")?;
// Quick check: look in KV store first if let Ok(Some(url)) = kv.get(&code).text().await { let destination_url = Url::parse(&url)?; return Response::redirect_with_status(destination_url, 307); }
let d1 = ctx.env.d1("DB_SHORTNER")?;
// Make the SQL query let stmt = d1.prepare("SELECT * FROM urls WHERE short_code = ? LIMIT 1"); let query = stmt.bind(&[code.into()])?;
// Run the SQL query match query.first::<UrlEntry>(None).await { Ok(Some(entry)) => { // Save to cache if found, keep for 24 hours if let Err(e) = kv .put(&entry.short_code, &entry.long_url)? .expiration_ttl(60 * 60 * 24) .execute() .await { // Keep going if we can't cache console_error!("Failed to cache URL({}): {:?}", &entry.long_url, e); }
Response::redirect_with_status(Url::parse(&entry.long_url)?, 307) } Ok(None) => Response::error("not-found", 404), Err(e) => { console_warn!("Failed to fetch URL for code({}): {:?}", code, e); Response::error("server-error", 500) }, } }) .run(req, env) .await}
wrangler.toml content
name = "blog-cloud-native-url-shortner"main = "build/worker/shim.mjs"compatibility_date = "2025-01-15"
[build]command = "cargo install -q worker-build && worker-build --release"
[observability]enabled = true
[[d1_databases]]binding = "DB_SHORTNER"database_name = "shortner-db"database_id = "88c47054-70b3-4ca7-876b-1a2039f393ce"
[[kv_namespaces]]binding = "URL_CACHE"id = "883da662962c4effb27334664b68bcea"
The code works in steps:
- Checking the KV cache first
- Falling back to D1 if no cache exists
- Caching successful lookups for later requests
Deployment
Deploy your worker and create the needed resources:
# Create KV storagenpx wrangler kv:namespace create URL_CACHE
# Deploy the workernpx wrangler deploy
Speed Test
Let’s check how fast it works:
curl -w "@curl-format.txt" https://XYZ.workers.dev/c/3def time_namelookup: 0.148814s time_connect: 0.168306s time_appconnect: 0.200331s time_pretransfer: 0.200521s time_redirect: 0.000000s time_starttransfer: 1.800326s ---------- time_total: 1.800535s
That’s slower than we want… Let’s try it again:
curl -w "@curl-format.txt" https://XYZ.workers.dev/c/3def time_namelookup: 0.002433s time_connect: 0.020938s time_appconnect: 0.050296s time_pretransfer: 0.050410s time_redirect: 0.000000s time_starttransfer: 0.103551s ---------- time_total: 0.103670s
This is way better! Now let’s try a new, unknown short code:
curl -w "@curl-format.txt" https://blog-cloud-native-url-shortner.martichou-andre.workers.dev/c/3defa time_namelookup: 0.003037s time_connect: 0.020590s time_appconnect: 0.057306s time_pretransfer: 0.057428s time_redirect: 0.000000s time_starttransfer: 0.247760s ---------- time_total: 0.248014s
curl-format.txt for the curious
time_namelookup: %{time_namelookup}s\n time_connect: %{time_connect}s\n time_appconnect: %{time_appconnect}s\n time_pretransfer: %{time_pretransfer}s\n time_redirect: %{time_redirect}s\ntime_starttransfer: %{time_starttransfer}s\n ----------\n time_total: %{time_total}s\n
These tests tell us a lot. The first call triggers a cold start, which takes about 1.8 seconds. Subsequent requests are much faster. KV calls actually hit the network because KV data isn’t available in every location at first; however, they are cached afterwards.
In this example, a valid short code hits the KV store quickly, while an unknown code forces the system to query the D1 database to confirm its absence.
Keep in mind that on the free plan, workers might take a bit longer to cold start. Once running, all plans follow the same eviction policies.
For more details, see #1 and #2.
What’s Next
This basic URL shortener is not ready for production. But you may consider these improvements:
- Add an admin API to create short URLs (link it to your D1 database)
- Include analytics tracking (e.g., referrer, user-agent, IP location)
- Set up CI/CD (using Cloudflare’s Git integration in the Worker settings)