Running a URL shortener on tako and SurrealDB
· 5 min read
shrtn.ink is a URL shortener running on tako, a Rust web framework I maintain. Part of the point of the project is to give the framework a production workload: a URL shortener is small enough to operate alone, but its core path is genuinely latency-sensitive. These are notes on how the pieces fit together and a few things that came out of running it.
Architecture
The apex domain is the Rust backend. It owns GET /{id} — the redirect — plus a small JSON API for shortening, stats, and per-user link management. The frontend is a separate Next.js app on a subdomain. The split keeps the redirect path free of everything else: no frontend rendering, no shared middleware, nothing competing with it. Storage is a single SurrealDB instance; persistence needs here are one table of links, two counters, and per-day latency samples.
The redirect path
A redirect is one point-read. Click counting and latency recording happen after the response, in a spawned task:
let url = store::get_url(&state.db, &id).await;
let elapsed_us = start.elapsed().as_micros() as u32;
if let Ok(Some(url)) = url {
tokio::spawn(async move {
let _ = store::record_redirect(&db, &id).await;
let _ = store::record_latency(&db, &today, elapsed_us, &cutoff).await;
});
found(url)
}Two decisions are worth making explicit. The response is a 302, not a 301: a permanent redirect would let browsers cache the hop and skip the server on repeat visits — faster for the visitor, but clicks would go uncounted and a link pointing at the wrong target would stay wrong in caches you don't control. And the bookkeeping is fire-and-forget: if the process dies between the response and the write, a click is lost. That's an acceptable failure mode for this data.
Measuring latency
The measured window is the store lookup, not the whole request — it isolates the part the backend can actually do something about. Samples append to a per-day array in SurrealDB, and the same write deletes the day that just fell out of the retention window, so pruning needs no scheduler. Percentiles are computed at read time by sorting the day's samples; at current volumes that costs nothing, and if it ever does, a fixed-bucket histogram replaces the array. The homepage renders the resulting p50/p95/p99 per day.
Derived counters and table events
The homepage also shows how many links exist. Originally that was a counter record the application incremented on every shorten. Then link deletion shipped, nothing decremented the counter, and the number quietly stopped meaning anything. The options were: count rows on every stats request (correct, but an O(n) scan), decrement from application code (the same bug waiting to happen again), or move the invariant into the database. SurrealDB table events run in the same transaction as the write that triggers them:
DEFINE EVENT OVERWRITE url_counter ON url
WHEN $event = "CREATE" OR $event = "DELETE"
THEN (
UPSERT counter:shortens
SET value = (value ?? 0) + (IF $event = "CREATE" THEN 1 ELSE -1 END)
);A reconcile statement at startup sets the counter to the live row count, so drift that predates the event — or somehow gets past it — survives at most one restart. The redirect counter stays a plain counter on purpose: it is cumulative history and includes clicks on links that were later deleted, so it cannot be recomputed from live rows.
Client quirks
Two SurrealDB client surprises, both cheap to hit and cheap to avoid. SELECT VALUE count() FROM url GROUP ALL reaches the Rust client as an object rather than an int; wrap the aggregate in a subquery and select the field out. ONLY on a statement whose value is a bare array makes the client treat the array's elements as rows. Both were caught by integration tests against the embedded in-memory engine (kv-mem), which are fast enough to run on every change.
Feedback into the framework
Running the app fed concrete changes back into tako: trailing-slash redirect handling, basic-auth middleware ergonomics, and a case for router introspection — the app currently maintains a hand-written list of reserved top-level routes that user slugs must not collide with, guarded by a test, when the router itself should be able to answer that question. That feedback loop was the reason to build this on a framework of mine in the first place. The framework lives at rust-dd/tako.