Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 081 / 100

Building a URL shortener in Rust: lessons from production

A small project with real concepts.

Expona Admin7 min read

Building a URL shortener in Rust: lessons from production isn't just a learning exercise—it's a chance to see how mature Rust patterns apply to real constraints you'll hit at scale. We've shipped systems like this across trading platforms and ride-hailing services, and each one taught us something about balancing correctness, performance, and operational simplicity.

Why a URL shortener?

URL shorteners are deceptively simple on the surface: map a long URL to a short key, store it, and redirect when someone visits. But in production, they force you to think about concurrency, database optimization, caching strategies, and deployment practices. You'll encounter the same patterns—connection pooling, cache invalidation, async I/O—that you'll use in CRM systems, e-commerce checkouts, or real-time event processors. By building this carefully, you'll develop instincts that transfer everywhere.

API: Axum routes for create and redirect

Axum is our framework of choice for web services. It's lightweight, uses Tokio under the hood, and the type system catches routing mistakes at compile time. Let's start with two endpoints: one to create a short link and one to follow it.

rust
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Redirect,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Serialize, Deserialize)]
pub struct CreateRequest {
    pub url: String,
}

#[derive(Serialize)]
pub struct CreateResponse {
    pub short_code: String,
}

pub struct AppState {
    pub db: sqlx::PgPool,
    pub cache: redis::aio::Connection,
}

pub async fn create_short_url(
    State(state): State,
    Json(req): Json<CreateRequest>,
) -> Result<(StatusCode, Json<CreateResponse>), StatusCode> {
    let short_code = generate_short_code();
    
    sqlx::query(
        "INSERT INTO links (short_code, long_url, created_at) VALUES ($1, $2, NOW())"
    )
    .bind(&short_code)
    .bind(&req.url)
    .execute(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((
        StatusCode::CREATED,
        Json(CreateResponse { short_code }),
    ))
}

pub async fn redirect_short_url(
    State(state): State<Arc<AppState>>,
    Path(short_code): Path<String>,
) -> Result<Redirect, StatusCode> {
    // Try cache first
    let url: Option<String> = redis::cmd("GET")
        .arg(&short_code)
        .query_async(&mut state.cache.clone())
        .await
        .ok()
        .flatten();

    if let Some(url) = url {
        return Ok(Redirect::permanent(&url));
    }

    // Fall through to database
    let row = sqlx::query("SELECT long_url FROM links WHERE short_code = $1")
        .bind(&short_code)
        .fetch_optional(&state.db)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let long_url = row
        .map(|r| r.get::("long_url"))
        .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Redirect::permanent(&long_url))
}

pub fn router(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/shorten", post(create_short_url))
        .route("/:code", get(redirect_short_url))
        .with_state(state)
}

Notice the flow: the create endpoint generates a short code (we'll use a base62 encoder for readability), inserts into the database, and returns immediately. The redirect endpoint checks the cache first, then hits the database. This layered approach keeps hot links fast while handling the long tail with database queries.

The State wrapper lets us inject dependencies cleanly. We use Arc because Axum tasks are spawned across threads, and we need cheap clones of our database pool and cache connection.

Storage: Postgres with SQLx

SQLx is compile-time checked SQL. It validates your queries against a running database at build time, so typos in column names or bind parameters fail before deployment. This has saved us from embarrassing runtime errors more times than we can count.

Our schema is minimal but purposeful:

rust
CREATE TABLE links (
    id BIGSERIAL PRIMARY KEY,
    short_code VARCHAR(12) NOT NULL UNIQUE,
    long_url TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL,
    accessed_at TIMESTAMP,
    access_count BIGINT DEFAULT 0
);

CREATE INDEX idx_links_short_code ON links(short_code);

We track accessed_at and access_count for analytics. In production, updating these on every redirect would hammer the database, so we batch writes or update only periodically. SQLx makes it straightforward to run migrations using sqlx migrate, which keeps your schema versioned alongside code.

Connection pooling is automatic with sqlx::PgPoolOptions:

rust
let pool = sqlx::postgres::PgPoolOptions::new()
    .max_connections(20)
    .connect("postgresql://user:pass@localhost/shortener")
    .await?;

let state = Arc::new(AppState {
    db: pool,
    cache: redis_client.get_async_connection().await?,
});

We tune max_connections based on expected load. Twenty is conservative; in production, we've run fifty across multiple services on the same cluster. The pool handles connection reuse transparently, so your async tasks don't block waiting for a free connection.

Redis is our cache layer. In a URL shortener, a small number of links (like a trending article) will be accessed thousands of times a minute, while most links sit idle. Caching the hottest few percent eliminates database load entirely.

We use a simple pattern: on redirect, check Redis first. If the key exists, use it. If not, query Postgres and—here's the key—don't write back to Redis immediately. Instead, we rely on a background refresh or let the second access populate the cache. This avoids the "cache stampede" problem where a link that expires gets hammered by concurrent cache misses.

A better approach for high-traffic links is to use a small async task that refreshes popular keys in the background:

rust
pub async fn cache_refresh_task(
    db: sqlx::PgPool,
    mut cache: redis::aio::Connection,
) {
    loop {
        tokio::time::sleep(tokio::time::Duration::from_secs(300)).await;

        let rows = sqlx::query(
            "SELECT short_code, long_url FROM links WHERE access_count > 100 LIMIT 1000"
        )
        .fetch_all(&db)
        .await
        .unwrap_or_default();

        for row in rows {
            let short_code: String = row.get("short_code");
            let long_url: String = row.get("long_url");
            let _ = redis::cmd("SETEX")
                .arg(&short_code)
                .arg(3600) // 1 hour TTL
                .arg(&long_url)
                .query_async(&mut cache)
                .await;
        }
    }
}

This refreshes the cache every five minutes for links with over 100 accesses. We set a one-hour TTL so if a URL changes (unlikely but possible), the cache eventually updates. The task runs in the background without blocking request handling.

Deploy: Distroless container

A distroless container strips away the OS, package manager, and shell—leaving only your binary and its runtime dependencies. For a Rust application, this means a container under 30 MB instead of 700 MB, and a smaller attack surface since there's no shell to exploit.

Build in two stages: compile with the full Rust toolchain, then copy the binary into a distroless base:

rust
FROM rust:1.75 as builder
WORKDIR /app
COPY Cargo.* ./
COPY src ./src
RUN cargo build --release

FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/shortener /shortener
ENTRYPOINT ["/shortener"]

We use cc-debian12 because our Postgres client needs glibc. For a fully static binary (if you use sqlx offline mode), you could use the static variant.

The resulting image starts fast, uses minimal memory, and deploys the same way whether you're running on Kubernetes, ECS, or a single VM. We've found that distroless images also make security scanning straightforward—fewer dependencies to audit.

Common mistakes

  • Not indexing the short_code column. Without an index, redirect lookups scan the entire table. We've seen this cause 10-second latencies at modest scale.
  • Updating analytics on every request. Each redirect becomes a write, which serializes under load. Cache the counts, batch-insert, or accept a few seconds of staleness.
  • Forgetting about collision handling. If your short code generator isn't seeded well or your base62 alphabet is too small, you'll generate duplicates. We've learned to add a uniqueness check in the insert and retry with a new code if the insert fails.
  • Setting cache TTL too high. A URL can change (if you allow updates), and a high TTL means stale data for a long time. One hour is usually safe; five minutes is more conservative.
  • Not monitoring pool exhaustion. When your connection pool is full, new requests queue. Monitor active/idle connections and idle timeout to catch leaks early.

Key takeaways

  • Axum's type-safe routing and state management make dependency injection straightforward and test-friendly.
  • SQLx's compile-time checking catches schema mismatches before they reach production.
  • Cache strategically: hit Redis first for redirects, but don't cache-stampede on misses—use a background refresh for hot links.
  • Connection pooling with sensible defaults (20-50 connections) handles most loads; monitor to tune further.
  • Distroless containers keep deployments lean and secure without sacrificing debuggability.
  • The patterns here—async I/O, connection pooling, cache layering—appear in every service we build, from ride-hailing to trading platforms.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles