Let’s build a simple URL shortener as a proof of concept using Cloudflare’s serverless platform and Rust. While this won’t be production-ready, it’s a good way to explore edge computing patterns and understand the trade-offs between different storage options in a distributed system.
Architecture Overview & Design Decisions
Before diving into implementation, let’s discuss the key choices for this proof of concept:
Why Cloudflare Workers?
- Global distribution: Runs close to users worldwide
- Pay-per-request: No server management, only pay for what you use
- Built-in security: DDoS protection comes free
Why Rust?
- Performance: Fast execution and quick cold starts
- WebAssembly: Compiles efficiently for edge environments
Why D1 + KV together?
- D1 (SQLite): Reliable storage with familiar SQL
- KV Cache: Faster reads for popular URLs
This setup lets us explore how different storage types work together in edge computing.
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. 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
Building Our Worker Code
Let’s break down our worker code into key components:
Complete Worker Code
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"
1. Setting Up Data Structures
First, we define a structure to represent our URL entries:
use serde::{Deserialize, Serialize};use worker::*;
#[derive(Debug, Serialize, Deserialize)]struct UrlEntry { short_code: String, long_url: String,}
2. Router and Health Check
We’ll set up a basic router with a ping endpoint for health checks:
#[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") }) // Other routes will go here .run(req, env) .await}
The console_error_panic_hook::set_once()
call is crucial when developing Rust for WebAssembly environments like Cloudflare Workers.
It ensures that if your Rust code panics, you’ll get proper error messages in the console instead of cryptic WebAssembly errors. This makes debugging much easier.
3. KV Cache Lookup
When a request comes in, we first check our KV cache for the short code:
.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); }
// Continue to database lookup if not in cache...
4. Database Lookup
If the URL isn’t in our cache, we query the D1 database:
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)) => { // Process the found entry...
5. Caching and Redirection
When we find a URL, we cache it and redirect the user:
// 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)
6. Error Handling
We need proper error handling for missing URLs or database issues:
Ok(None) => Response::error("not-found", 404), Err(e) => { console_warn!("Failed to fetch URL for code({}): {:?}", code, e); Response::error("server-error", 500) },
Technical Deep Dive
HTTP Redirect Choice (307 Temporary Redirect)
We use HTTP 307 instead of 301 (Permanent) because:
- Flexibility: We can change where URLs point without browser cache issues
- Analytics: Preserves the original request method if needed
- Simple approach: For a demo, temporary redirects make more sense
Cache Strategy
Our 24-hour KV cache is a simple approach that:
- Reduces database hits: Popular URLs get served from cache
- Balances freshness: Updates propagate within a day
- Keeps it simple: Good enough for learning and light usage
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://XYZ.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
Understanding the Performance
These tests reveal several important performance characteristics:
Cold Start vs Warm Performance The first request (~1.8 seconds) represents a cold start. Cloudflare Workers use V8 isolates which start in under 5ms, but the high latency here is likely due to:
- Initial database connection setup to D1
- Network path optimization as requests are routed through Cloudflare’s global network
- Initial KV cache population in that specific edge location
KV Cache Behavior The dramatic improvement in the second request (~0.1 seconds) demonstrates KV’s eventual consistency model:
- First access: KV reads must go to Cloudflare’s central data stores, which can add 100ms+ latency
- Subsequent reads: Data is cached locally at the edge location, providing sub-10ms response times
- Cache propagation: Popular keys get cached closer to users automatically
Database vs Cache Performance The unknown short code test (~0.25 seconds) shows what happens when:
- KV cache doesn’t contain the key (it’s new)
- The system falls back to querying the D1 database
- D1 database access takes longer than KV cache but is still reasonably fast
Production Implications
- Popular URLs benefit from aggressive edge caching and serve in ~100ms globally
- New URLs will experience ~250ms latency on first access from each region
- Cache warming happens automatically as URLs get accessed
Also check those two links (see #1 and #2) for more information about Workers start
Production Considerations
While this implementation works as a proof-of-concept, here are some key areas you’d need to address for any real-world usage:
Security & Abuse Prevention
- Rate limiting: Implement request throttling per IP to prevent abuse
- Input validation: Basic URL validation to prevent malformed entries
Monitoring & Cost Awareness
- Basic metrics: Track cache hit rates and request patterns
- Cost monitoring: With Cloudflare’s generous free tiers, this demo would cost almost nothing, but it’s worth understanding the pricing model
Simple Economics
- Workers: 100K requests/day on free plan, 10M/month on paid ($5)
- KV: 100K reads/day on free plan, 10M/month on paid
- D1: 5M reads/day on free plan, 25B/month on paid
For a proof-of-concept or small personal project, the free tier is more than sufficient.
Data Consistency
- Cache timing: Our 24-hour KV cache means updates could take up to a day to propagate
- Simple approach: For a demo, this eventual consistency is fine
What’s Next
This basic URL shortener is not ready for production, 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)