A link shortener looks simple on paper: accept a long URL, store it under a short key, redirect visitors when they hit that key. The logic fits in a hundred lines. But the project is interesting precisely because its simplicity forces you to confront the actual shape of the ecosystem you are building in, and in Rust that ecosystem has changed substantially in the last few years.
Building ezli.me is one recent account of going through this exercise end to end. What makes it worth examining is not the feature set of the finished product, which is straightforward, but what each decision along the way reveals about where Rust web development actually stands.
The Framework Question Is Mostly Settled
For most of Rust’s web history, Actix-web dominated throughput benchmarks and mindshare. It is still fast, routinely appearing at the top of the TechEmpower Framework Benchmarks, and its actor model was genuinely interesting. But the ergonomics of building applications with it could be rough, especially around handler types, extractors, and middleware composition.
Axum, released by the Tokio team in 2021, changed the calculus. It is built on top of Tower, which means your middleware is composable, testable, and shared with the broader Tower ecosystem. Handlers are ordinary async functions, and the extractor system is type-driven in a way that makes request parsing feel natural.
For a link shortener, the core of an Axum application looks like this:
use axum::{
extract::{Path, State},
response::Redirect,
routing::{get, post},
Json, Router,
};
use std::sync::Arc;
#[tokio::main]
async fn main() {
let state = Arc::new(AppState::new().await);
let app = Router::new()
.route("/:code", get(redirect_handler))
.route("/api/links", post(create_link))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn redirect_handler(
Path(code): Path<String>,
State(state): State<Arc<AppState>>,
) -> Result<Redirect, StatusCode> {
match state.db.lookup(&code).await {
Some(url) => Ok(Redirect::permanent(&url)),
None => Err(StatusCode::NOT_FOUND),
}
}
The Path and State extractors handle deserialization and dependency injection respectively. There is no registration step, no macro magic; the types are enough. This is where Rust’s type system does actual work for you rather than just preventing bugs.
Axum 0.7, released in late 2023, moved to Hyper 1.0, which completed the stabilization of the underlying HTTP primitives. The dependency tree is now stable and auditable in a way it was not two years ago.
Short Code Generation Is Surprisingly Opinionated
The core algorithmic problem in a link shortener is mapping long URLs to short codes without collision. There are three main approaches, and the choice you make has cascading effects on your storage schema and on how the service behaves under load.
Sequential IDs encoded in base62. Assign an auto-incrementing integer ID, then encode it as a base62 string. Six characters in base62 gives you 62^6 = 56 billion possible codes, which is more than enough for any personal or small-scale service.
fn encode_base62(mut n: u64) -> String {
const CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
if n == 0 {
return "0".to_string();
}
let mut digits = Vec::new();
while n > 0 {
digits.push(CHARS[(n % 62) as usize]);
n /= 62;
}
digits.reverse();
String::from_utf8(digits).unwrap()
}
The upside is zero collision probability and a trivially simple storage lookup. The downside is that codes are predictable: if abc123 exists, abc124 probably does too, which is a problem if you want link privacy.
Random codes with collision checking. Generate a random string of length 6 or 7, check if it exists in the database, and retry on collision. The collision probability for 6-character base62 codes is negligible until you approach millions of links, but the retry loop adds a round-trip to every write.
Hash of the target URL. Take the first N characters of a hash of the target URL. This makes the same URL always produce the same code, which is useful for deduplication. The tradeoff is that two different users shortening the same URL get the same short code, which may or may not be desirable depending on whether you want per-user analytics.
For a personal or small-team service like ezli.me, the sequential ID approach with base62 encoding is usually the right choice. It is simple, deterministic, and fast.
Storage Is Where the Real Decisions Are
Redirects need to be fast. The p99 latency on a lookup request should be in single-digit milliseconds if you are hitting a warm database. This shapes the storage choice more than anything else.
SQLite with sqlx is the first thing most people reach for, and it is a reasonable choice for low-to-medium traffic. sqlx provides compile-time verified queries, which means your SQL is checked against the actual schema at build time:
let row = sqlx::query!(
"SELECT target_url FROM links WHERE code = ?",
code
)
.fetch_optional(&pool)
.await?;
If you rename the target_url column in your migration without updating this query, the build fails. This is one of those features that feels minor until you have a production incident caused by a renamed column that only showed up at runtime.
SQLite’s write concurrency is limited by its file-level locking, but a link shortener’s read-to-write ratio is very high. For a personal service, SQLite is often the correct answer.
PostgreSQL is the choice when you need concurrent writes, complex analytics queries, or multi-node deployment. The sqlx API is identical; you swap the connection URL. The operational overhead is real, though, and most link shorteners do not need it.
Redis or an in-process cache becomes important when you have hot links that get clicked thousands of times per day. A lookup that hits SQLite on every request will be fine at low traffic but will degrade under sustained load. Wrapping the database lookup with moka, a high-performance concurrent cache for Rust, adds maybe twenty lines and eliminates the database entirely for cached codes:
use moka::future::Cache;
let cache: Cache<String, String> = Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(300))
.build();
async fn resolve(code: &str, cache: &Cache<String, String>, db: &Pool) -> Option<String> {
if let Some(url) = cache.get(code).await {
return Some(url);
}
let url = db_lookup(code, db).await?;
cache.insert(code.to_string(), url.clone()).await;
Some(url)
}
The cache is bounded, async-safe, and does not require a separate process. This matters for deployment.
The Deployment Story Favors Rust More Than You Might Expect
Rust produces statically linked binaries by default when targeting x86_64-unknown-linux-musl. The resulting binary has no runtime dependencies, no interpreter, no shared libraries. You can copy it to a server running any Linux kernel from the last decade and it will run.
For a Docker-based deployment, this means your production image can be a distroless or scratch container:
FROM rust:1.82 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/ezlime /ezlime
COPY --from=builder /app/migrations /migrations
ENTRYPOINT ["/ezlime"]
The resulting image is typically under 20MB. A comparable Node.js or Python service, with its runtime and dependencies, is an order of magnitude larger. Memory consumption follows the same pattern: a Rust link shortener handling moderate traffic will sit comfortably under 10MB of resident memory.
This is not a performance trick. It is just the consequence of having no garbage collector, no managed runtime, and no lazy-loaded modules.
What the Error Model Gets You
One of the quieter benefits of building this kind of service in Rust is how the error model interacts with the handler types. Axum handlers can return Result<T, E> where E implements IntoResponse. This means you can define a single application error type and have the framework handle conversion to HTTP responses:
#[derive(Debug)]
enum AppError {
NotFound,
Database(sqlx::Error),
InvalidUrl(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::NotFound => StatusCode::NOT_FOUND.into_response(),
AppError::Database(e) => {
tracing::error!("database error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
AppError::InvalidUrl(url) => (
StatusCode::BAD_REQUEST,
format!("invalid URL: {}", url),
).into_response(),
}
}
}
Every handler that returns Result<_, AppError> gets this mapping for free. The ? operator propagates errors up the call stack, and the framework converts them to responses at the boundary. You never write match err { ... return HttpResponse::InternalServerError } inside a handler; the types enforce the separation.
The Compile Time Tax
Rust web development has one known cost that is worth naming directly: compile times. A fresh build of a project with Axum, sqlx, tokio, and a handful of other crates takes several minutes on a mid-range developer machine. Incremental builds are much faster, but the initial build and any build that touches a dependency are slow.
cargo-watch with cargo check (which skips codegen) brings the feedback loop down to a few seconds for most changes. The sqlx compile-time query checking requires offline mode (SQLX_OFFLINE=true) with a pre-generated query cache to avoid a database connection on every build. Both are standard practice in the ecosystem, but they are things you learn the hard way if you come from Node or Python.
For a small service like a link shortener, none of this matters once the project is wired up. The compile time is a one-time cost per development session. In production, there is no JIT warmup, no cold start latency spike, no garbage collection pause to worry about.
The Honest Summary
Building a link shortener in Rust is not the most efficient path to a working link shortener. If you need one today, a Node.js service backed by Redis and Postgres has better ecosystem tooling, faster iteration speed, and a larger pool of developers who can maintain it.
But if you are learning Rust, or if you are evaluating whether Rust is appropriate for your backend services, a link shortener is close to the ideal test project. It is small enough to finish, large enough to touch every layer of the stack, and simple enough that the decisions are visible rather than buried under framework magic.
The ezli.me writeup is a concrete example of what that looks like in practice. The Rust web story in 2026 is more mature than it was three years ago: Axum is stable, sqlx is stable, the async runtime has converged on Tokio, and the deployment story is genuinely strong. The compile time tax is real and the ecosystem is smaller than Go or Node. Those tradeoffs have not changed. What has changed is that the rough edges have been filed down enough that you are not fighting the ecosystem just to get a handler to compile.