Link Shortener Service in Rust

Microservices

⏱️ 2h 50min
📦 17 modules
Rust

Build a URL shortener in Rust — a hands-on mini-course

A link shortener feels like magic until you build one — then it becomes one of the cleanest tours of a real web service you can take. In a couple of hundred lines of Rust you touch async I/O, routing, request validation, shared state, and HTTP semantics. We'll grow it one small piece at a time.

The idea, in one sentence

A shortener stores a mapping from a generated code to a long URL, and redirects visitors who hit the code. The whole domain model is two fields:

struct Shortener {
    links: HashMap<String, Url>, // code -> URL
    counter: u64,                // source of codes
}

links is the lookup table every redirect reads. counter is a number that only ever goes up; each new link takes the next value and turns it into a short code. That's the entire state of the service — everything below is plumbing around these two fields.

An async server in a handful of lines

A server spends almost all its time waiting on the network, not computing — it is I/O-bound. So the right model is async: one thread parks thousands of idle connections instead of blocking a whole thread per request. In Rust that means tokio for the runtime and axum for the HTTP layer.

Start with just the entry point:

#[tokio::main]
async fn main() -> Result<()> {
    // ...
    Ok(())
}

#[tokio::main] rewrites this into a normal main that boots a multi-threaded async executor under the hood. The Result is the catch-all type from the anyhow crate, so every ? inside can bubble a failure straight up without you defining error types first.

Now bind a socket and hand it to axum:

let listener =
    TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;

bind asks the OS for the port; it's awaited because acquiring a socket can block, and the ? turns a failure (port already in use, say) into an early return. axum::serve then loops forever — accepting connections and routing each one through app.

Two endpoints, two verbs

A shortener is, almost literally, two routes. Add them to the router one at a time. First the write path:

let app = Router::new()
    .route("/new", post(create_link));

POST /new creates: it takes a long URL and mints a new short code, changing the server's state. The post(...) helper says this route only answers the POST method.

Then the read path:

    .route("/{code}", get(follow_link));

GET /{code} reads: it looks up a code and redirects, changing nothing. Matching the verb to the side effect isn't pedantry — browsers, caches, and crawlers all treat GET and POST differently, and that's what makes the service behave on the real web.

Let the framework validate your input

Here's where axum earns its keep. Instead of parsing the body by hand, you declare the shape you expect as a struct:

#[derive(Deserialize)]
struct CreateLink {
    link: Url, // not String
}

The field is a Url (from the url crate), not a String. That single choice means a malformed address is rejected while the body is still being parsed — it can never reach your logic disguised as valid.

Then you ask for it as a typed parameter on the handler:

async fn create_link(
    Json(req): Json<CreateLink>,
) -> impl IntoResponse {
    // req.link is already a valid URL
}

The Json<T> extractor deserializes the body before your code runs, and answers 400 Bad Request on its own if it doesn't fit. You write zero validation code and still can't be handed garbage — the types did the work.

Shared state without data races

Both handlers touch the same store, from many requests at once. Rust won't let you share mutable state casually, so wrap it once in a shared, lockable handle:

type SharedState = Arc<RwLock<Shortener>>;

Arc is an atomically reference-counted pointer, so every request task can cheaply hold the same allocation. The RwLock (the async one from tokio) guards the data with one rule: many readers, or one writer — never both at once.

The write path takes the exclusive lock:

async fn create_link(
    State(state): State<SharedState>,
    Json(req): Json<CreateLink>,
) -> impl IntoResponse {
    let code =
        state.write().await.register(req.link);
    // ...
}

state.write().await waits for exclusive access, then register inserts the link and returns its fresh code. Only one writer runs at a time, so two simultaneous creations can never corrupt the map.

The read path takes a shared lock instead:

async fn follow_link(
    State(state): State<SharedState>,
    Path(code): Path<String>,
) -> impl IntoResponse {
    let target =
        state.read().await.lookup(&code);
    // ...
}

state.read().await lets unlimited redirects run in parallel — they only read — which matches reality, where lookups vastly outnumber creations. The compiler guarantees you never observe a half-written map, so there are simply no races to debug.

From a number to a short code

The laziest code is the counter itself: link number 1000000 becomes /1000000. It's long, and it leaks how many links you have. The fix is to encode the counter in base62 — the 10 digits plus 26 lower- and 26 upper-case letters.

Set up the alphabet and an output buffer:

const ALPHABET: &[u8] = b"0123...xyz...XYZ"; // 62
let mut out = Vec::new();

Now peel off one base-62 digit at a time:

loop {
    out.push(ALPHABET[(n % 62) as usize]);
    n /= 62;
    if n == 0 { break; }
}

n % 62 picks the next character and n /= 62 shifts to the next "digit" — exactly how you'd convert a number to any base by hand. The loop ends the moment nothing is left.

Because we built it from the lowest digit up, the characters come out reversed, so flip them at the end:

out.reverse();
String::from_utf8(out).unwrap_or_default()

Base62 packs that millionth link into just 4c92 — four URL-safe characters, with no +, /, or = to escape. Increment, encode, store.

The redirect that actually works

Resolving a code is a lookup plus a redirect, and the status code you choose matters more than people expect:

match target {
    Some(url) => Redirect::temporary(url.as_str())
        .into_response(),
    None => StatusCode::NOT_FOUND.into_response(),
}

A hit becomes a redirect; a miss becomes a 404. Redirect::temporary sends a 307, and the into_response() calls let two different shapes — a redirect and a bare status — return from the same handler.

Prefer 307/308 over the legacy 301/302: the modern pair preserves the request method and makes the temporary-vs-permanent choice explicit. A 301 in particular is cached by browsers forever — wonderful until the day you need to repoint a link and simply can't.

Where to go next

The toy is honest, but production wants a few more things:

  • Unguessable codes — sequential codes let anyone enumerate every link. Mix in randomness or hash the counter.
  • Persistence — that HashMap evaporates on restart. Swap it for Redis or Postgres behind the same register/lookup interface.
  • Custom aliases, expiry, click analytics — each is just another field and a few lines.

Build this once and "REST", "async handlers", and "shared state" stop being vocabulary. You've held the whole stack in your hands — and it fit in one afternoon.

Practice