DEV Community

SEN LLC
SEN LLC

Posted on

Building a URL Shortener in Rust with axum, SQLite, and 30 Lines of base62

Building a URL Shortener in Rust with axum, SQLite, and 30 Lines of base62

The URL shortener is the "hello world" of backend engineering. It's also one of the few hello-world projects that produces something you'd actually deploy. I wrote one in Rust — axum for routing, rusqlite with the bundled feature for a self-contained binary, and a hand-written base62 encoder because the crate version takes longer to read than to write. The whole thing is an 11 MB Alpine container.

📦 GitHub: https://github.com/sen-ltd/url-shortener-rs

screenshot

Every backend dev eventually writes a URL shortener. It shows up in system design interviews, tutorials, and take-home projects. The reason is that it's a genuinely complete backend in miniature: you've got HTTP routing, a database with a uniqueness constraint, a tiny domain-specific encoding problem (slugs), a write-heavy side-effect (click tracking), some auth on the admin endpoints, and a rate limit story. That's most of "backend" in 400 lines of code.

The part tutorials usually get wrong is that they reach for too many dependencies. A URL shortener doesn't need a migrations framework, a query builder, an ORM, a URL parser crate, a base62 crate, a rate limit crate, or Redis. It needs a router, an embedded database, and a few dozen lines of logic. I wanted to see what the disciplined version looks like. Here's what I ended up with.

The stack

axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.5", features = ["trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["std", "clock", "serde"] }
anyhow = "1"
Enter fullscreen mode Exit fullscreen mode

That's the whole dependency list. Nine crates, most of which are axum's transitive infrastructure showing up at the top level. The two interesting choices:

  1. rusqlite with the bundled feature. bundled compiles SQLite from source and statically links it into the binary. No libsqlite3 on the host, no apk add sqlite, no dynamic linking. The release container has exactly two files on disk that matter: the Alpine runtime and our binary. SQLite is just part of the binary. For a single-process shortener that will never outgrow SQLite anyway, this is unambiguously the right call — it's the reason the final image is 11 MB.

  2. No URL parser, no base62 crate. The rules a shortener actually cares about — "starts with http:// or https://" and "shorter than 2048 bytes" — fit in 8 lines. The base62 encoder/decoder is 30. Neither is worth a dependency, and writing them makes the project readable: when a new reader asks "how does a slug get made," the answer is a single file they can read in ten seconds instead of "go look at the crate source."

The 30-line base62 encoder

This is the piece that convinced me to do the whole thing from scratch. Base62 is the compact URL-safe encoding every shortener reaches for: the alphabet is 0-9A-Za-z, which gives you 62 symbols — dense enough that even a counter of ten billion encodes to 6 characters, but still contains only characters that survive every URL-parsing layer without escaping.

const ALPHABET: &[u8; 62] =
    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

pub fn encode(mut n: u64) -> String {
    if n == 0 {
        return "0".to_string();
    }
    // A u64 max value fits in 11 base62 digits.
    let mut buf = [0u8; 11];
    let mut i = buf.len();
    while n > 0 {
        i -= 1;
        buf[i] = ALPHABET[(n % 62) as usize];
        n /= 62;
    }
    std::str::from_utf8(&buf[i..]).unwrap().to_string()
}

pub fn decode(s: &str) -> Option<u64> {
    if s.is_empty() {
        return None;
    }
    let mut n: u64 = 0;
    for &b in s.as_bytes() {
        let v = match b {
            b'0'..=b'9' => (b - b'0') as u64,
            b'A'..=b'Z' => (b - b'A') as u64 + 10,
            b'a'..=b'z' => (b - b'a') as u64 + 36,
            _ => return None,
        };
        n = n.checked_mul(62)?.checked_add(v)?;
    }
    Some(n)
}
Enter fullscreen mode Exit fullscreen mode

A couple of small things that are easy to get wrong:

  • 0 encodes to "0", not empty. The loop is "while n > 0," so if you don't handle zero before entering it you get an empty string and then fail to decode your own slug.
  • checked_mul and checked_add in the decoder. Without them, decode("zzzzzzzzzzzz") — a 12-character string that's beyond u64::MAX — silently wraps and returns a garbage value. With them, you get None and the route handler turns that into a 404.
  • MSB-first fill into a 11-byte buffer. You can also push bytes onto a Vec and reverse it, but writing from the back of a fixed buffer is faster and makes the invariant ("this never grows past 11") obvious.

There are round-trip tests for the whole dense range 0–5000 plus hand-picked big values (u64::MAX, u64::MAX - 1, a few billions). Writing the tests took longer than writing the code.

The shortener logic

The /shorten handler is the core, and the interesting design question is slug generation. The naive approach — generate a random 6-char string, INSERT, catch the collision, retry — works, but it's both slower than it needs to be and strictly less informative: collisions only tell you "bad luck," never "that slug is already taken by this specific URL." The deterministic approach I went with is:

  1. Insert a row with a placeholder unique string (nanosecond timestamp suffix).
  2. Read back the rowid SQLite gives you.
  3. Base62-encode rowid + 1000. The + 1000 is there so the very first slug is "G8" rather than "0" — the extra character makes it feel more like a real short URL and avoids the aesthetic dead zone of 1-character slugs.
  4. UPDATE the row with the real slug.

For custom slugs, the flow is simpler: validate, then INSERT, and if the UNIQUE constraint fires, convert it to a 409 Conflict:

match conn.execute(
    "INSERT INTO links (slug, long_url, created_at) VALUES (?1, ?2, ?3)",
    params![slug, long_url, created_at],
) {
    Ok(_) => Ok(Some(row)),
    Err(rusqlite::Error::SqliteFailure(e, _))
        if e.code == rusqlite::ErrorCode::ConstraintViolation =>
    {
        Ok(None) // Surfaced as 409 Conflict by the route handler.
    }
    Err(e) => Err(e.into()),
}
Enter fullscreen mode Exit fullscreen mode

This is my favorite rusqlite pattern: instead of a two-step "does it exist? → insert" dance with a race condition between the two queries, you let the database do the atomic uniqueness check and interpret the specific error code. It's one round trip and race-free.

Click tracking

The redirect handler is a four-line affair: increment the counter, read the row back, build a 302 response. The whole thing is a single UPDATE:

UPDATE links SET clicks = clicks + 1 WHERE slug = ?1
Enter fullscreen mode Exit fullscreen mode

This is race-free because SQLite serializes writes. On a busy shortener this is also the bottleneck — SQLite is a single-writer database, so your write throughput is capped at whatever your disk can handle serially, typically on the order of 1000 writes/sec on NVMe in WAL mode. For a portfolio-scale shortener that's plenty; for a production one, you'd want a separate aggregation layer (Redis counter → periodic SQL flush) to avoid synchronizing every click with fsync.

Notice that there's no EventEmitter, no "click tracking service," no separate table for events. Just a counter column. This is another thing I think small-project tutorials get wrong: reaching for event sourcing before you've earned it makes the code worse, not more scalable.

The rate limiter

This is the one section I want to be unusually honest about, because a rate limiter written like this has no business in production and I want you to know why. The whole thing:

pub struct RateLimiter {
    capacity: f64,
    refill_per_sec: f64,
    buckets: Mutex<HashMap<String, Bucket>>,
}

#[derive(Clone, Copy)]
struct Bucket { tokens: f64, last: Instant }

impl RateLimiter {
    pub fn allow(&self, key: &str) -> bool {
        let now = Instant::now();
        let mut buckets = self.buckets.lock().unwrap();
        let b = buckets.entry(key.to_string()).or_insert(Bucket {
            tokens: self.capacity, last: now,
        });
        let elapsed = now.duration_since(b.last).as_secs_f64();
        b.tokens = (b.tokens + elapsed * self.refill_per_sec).min(self.capacity);
        b.last = now;
        if b.tokens >= 1.0 {
            b.tokens -= 1.0;
            true
        } else {
            false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It is in fact a correct token bucket — tests cover capacity exhaustion, per-key independence, and refill over time. But it has three weaknesses that matter:

  1. It's in-process. Two replicas behind a load balancer double the effective rate for every client, silently. In production you want the limiter to live at the edge — nginx's limit_req, a CDN rule, or a Redis-backed shared counter.
  2. It's unbounded in memory. Every new IP adds a HashMap entry. The code includes a gc() method to prune old buckets, but nothing calls it. For a demo that's fine; for production you'd want a time wheel or lru crate.
  3. The "key" is the TCP peer IP. Behind any reverse proxy, every request looks like it comes from 127.0.0.1, and one misbehaving client can block everyone. Behind a proxy you'd read X-Forwarded-For (and then sanitize it, because it's client-controlled).

I left all three in because any reader who spots them has already learned the important lesson. The point of including a rate limiter is "here is what a token bucket looks like," not "here is a production-ready limiter in 50 lines," because that second thing does not exist.

Admin delete and the env-token pattern

The DELETE /:slug endpoint is gated by an Authorization: Bearer <ADMIN_TOKEN> header, where ADMIN_TOKEN comes from an env var read at startup. There's one non-obvious rule: if ADMIN_TOKEN is unset, delete is disabled outright:

let expected = state.admin_token.as_ref().ok_or(AppError::Unauthorized)?;
Enter fullscreen mode Exit fullscreen mode

The alternative — "if no token is set, let anyone delete" — is a footgun. The safe default for a self-hostable binary is that admin operations require explicit opt-in, and the opt-in is "set this env var." It's the same reason ssh-keygen prompts you for a passphrase: secure-by-default, even though it's a bit more friction.

Tests

There are 34 tests: 16 unit tests for base62 round-trips, the rate limiter, URL validation, and slug validation, plus 18 integration tests that build the full router with an in-memory SQLite database and drive it via tower::ServiceExt::oneshot. No sockets, no live HTTP — the whole suite runs in about 60ms. The house pattern is a build_app(state) -> Router factory:

pub fn build_app(state: AppState) -> Router {
    Router::new()
        .route("/", get(health::index))
        .route("/health", get(health::health))
        .route("/shorten", post(shorten::shorten))
        .route("/:slug", get(redirect::redirect).delete(admin::delete))
        .route("/:slug/info", get(redirect::info))
        .layer(axum::middleware::from_fn(logging::log_requests))
        .with_state(state)
}
Enter fullscreen mode Exit fullscreen mode

Because AppState is Clone (both inner fields are Arc), tests can build the same state once and clone it into a fresh router for every assertion — that's how the "create a link in one router, verify the click count via /info in another" tests work. It's the same underlying Db and RateLimiter across all of them.

The one gotcha is that /shorten uses the ConnectInfo<SocketAddr> extractor to pull the client IP for rate limiting. In oneshot there's no peer addr, so the extractor fails. The fix is a single line on the test helper:

build_app(state).layer(MockConnectInfo(SocketAddr::from(([127,0,0,1], 0))))
Enter fullscreen mode Exit fullscreen mode

MockConnectInfo is an axum-provided layer specifically for this case. It's the kind of thing you only find out by hitting the wall first.

Tradeoffs I'd want you to notice

  • Rate limit is per-process, not per-cluster. See the full honesty section above.
  • SQLite is single-writer. For shortener workloads (read-heavy, bursty writes on creation, amortizable click increments) it's more than enough. At ~1000 writes/sec, you should reach for a real DB — or, better, flush click increments to SQLite in batches.
  • Click tracking has no aggregation layer. Every redirect does a synchronous UPDATE and SELECT. For lower latency and higher throughput you'd count clicks in-memory and flush periodically.
  • No URL sanitization beyond scheme + length. We don't block javascript: (the scheme check rejects it), but we also don't block shortening a link to the shortener itself, which would let you build a redirect loop. If the service is internet-facing, you want at least a denylist.
  • The HTML form does no CSRF protection. It's served same-origin and the API is JSON-only, so in practice this is fine for a demo, but a real product would add it.

Try it in 30 seconds

docker build -t url-shortener-rs .
docker run --rm -d -p 8000:8000 -e ADMIN_TOKEN=changeme url-shortener-rs

curl -sS -X POST http://localhost:8000/shorten \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://sen.ltd"}'
# {"slug":"G9","short_url":"http://localhost:8000/G9",...}

curl -sI http://localhost:8000/G9 | grep -i location
# location: https://sen.ltd

curl -sS http://localhost:8000/G9/info
# {"slug":"G9","long_url":"https://sen.ltd","clicks":1,...}

curl -sS -X DELETE http://localhost:8000/G9 \
  -H 'Authorization: Bearer changeme'
# {"deleted":"G9"}
Enter fullscreen mode Exit fullscreen mode

Full source is on GitHub. The binary is 4 MB, the Alpine container is 11 MB, and the whole thing — including tests, Dockerfile, and the HTML page — is about 1100 lines. If you're learning Rust for the server side, I think a URL shortener with this shape is a better first project than any of the HTTP-echo/"blog API" examples. You end with something you'd host.

Top comments (0)