Designing a latency-critical redirect path on tako and SurrealDB
· 9 min read
shrtn.ink is a redirect service — a URL shortener — that I run on tako, a Rust web framework I maintain. It serves real links, and it is built around a single constraint that ends up shaping almost every decision below: the redirect is a latency-critical point-read, and the design's whole job is to keep everything else off that path.
The service boundary
The apex domain is the Rust backend. It owns exactly two things: GET /{id} — the redirect — and a small JSON API for shortening, stats, and per-user link management. The frontend is a separate Next.js application on the app subdomain. That split is not cosmetic. The redirect handler shares no process, no middleware stack, and no event loop with page rendering, so nothing competes with it: a slow server-render or a heavy marketing route cannot introduce head-of-line latency on a hop that should cost microseconds. Storage is a single SurrealDB instance the backend talks to over HTTP, and the entire persistence surface is one table of links, two counters, per-day latency samples, and a per-link-per-day click rollup.
There is a tell in how the apex root itself redirects. A request to / returns a 301 — a permanent redirect — to the app subdomain. That is the opposite of the choice the short-link path makes, and the contrast is the whole point of the next section: permanence is something you decide per route, by intent, not a default you reach for.
The redirect: one point-read
Resolving a short code is a single point-read. SurrealDB records are addressed by id, so type::record is a direct key lookup rather than a query that has to be planned and scanned, and the expiry check rides along in the same statement:
SELECT VALUE target FROM type::record('url', $id)
WHERE expires_at = NONE OR expires_at > time::now()Expiry is evaluated here, at read time, not by a background job. An expired link stops resolving the instant it expires because the predicate is part of the read; nothing has to wake up and sweep it. (That is the first instance of a pattern the whole system leans on — I'll come back to it.) The handler times the lookup, then makes two deliberate choices:
let start = Instant::now();
let url = store::get_url(&state.db, &id).await;
let elapsed_us = start.elapsed().as_micros() as u32;
if let Ok(Some(url)) = url {
let db = state.db.clone();
tokio::spawn(async move {
// day + retention cutoff computed here, off the response path
let _ = store::record_redirect(&db, &id, &today).await;
let _ = store::record_latency(&db, &today, elapsed_us, &cutoff).await;
});
found(url) // 302: keep the server on every click
} else {
temporary(format!("{PROTOCOL}://app.{host}")) // 307: unknown id -> app
}A hit returns 302 Found, not a 301. A permanent redirect would let browsers and intermediaries cache the hop and skip the server on repeat visits — faster for the visitor, and fatal to everything the service exists to do. A cached 301 is uncountable, because the request never arrives, and effectively irrevocable, because you cannot correct or disable a link that resolves inside a cache you don't control. The 302 keeps the server on the path for every click, and that is the price of counting and revocability. A miss bounces to the app with a temporary (307) redirect. And the apex root, which carries none of those constraints, is a 301 — because there you genuinely do want the hop cached. Three redirect codes, three intents.
Off-path bookkeeping, at-most-once by design
Notice what the handler does once it has the target: it spawns the bookkeeping and returns. The click increment, the global redirect counter, the per-day rollup, and the latency sample all happen in a detached task, after the response is already on its way:
UPDATE type::record('url', $id) SET clicks = (clicks ?? 0) + 1;
UPSERT counter:redirects SET value = (value ?? 0) + 1;
UPSERT type::record('clickday', [$id, $day])
SET url = type::record('url', $id), day = $day, count = (count ?? 0) + 1;This is a consistency decision, and it is worth naming precisely. The redirect's latency and availability must not be coupled to the write path's. If the process dies between sending the response and finishing the writes, the click is lost. That is at-most-once accounting, chosen deliberately over at-least-once: for click analytics, undercounting by an epsilon on a crash is acceptable, whereas adding write latency — or a write's availability — to the critical path is not. Three writes per click is genuine write amplification, but it sits off the path that has an SLO, so it costs nothing that matters. If I ever needed at-least-once, the honest fix would not be to move the writes back inline; it would be to put a durable log in front of them and leave the redirect exactly as cheap as it is now.
The same reasoning runs in the other direction on the shortening path, and there it produces the opposite decision:
// custom slug: the conflict is part of the response contract, so await
match store::try_insert_url(&db, &custom, &url, owner, expires).await {
Ok(true) => {} // created
Ok(false) => return (StatusCode::CONFLICT, "slug is already taken").into_response(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response(),
}
// random slug: the outcome isn't observable to the caller, so don't wait
let id = rng::alphanumeric(8);
tokio::spawn(async move {
let _ = store::insert_url(&db, &id, &url, owner, expires).await;
});A custom slug can collide with one a user already claimed, so its insert is awaited: the conflict is part of the response contract — the caller has to receive a 409. A random slug's insert is not awaited. The id is drawn from a 62⁸ keyspace, roughly 2.2 × 10¹⁴, so at any realistic number of links the per-insert collision probability is negligible, and the write's outcome is not observable to the caller in any case. Same operation, two latency profiles, and the thing that decides between them is whether the result is part of what you promised the caller.
The random id itself is drawn with rejection sampling, which is the small detail that keeps it uniform:
// draw 6 bits, reject the 2 values past the 62-char set -> unbiased
let v = (rng.next_i32() as u32) & 63;
if (v as usize) < CHARSET.len() {
out.push(CHARSET[v as usize] as char);
}A naïve index % 62 would bias the first two characters of the alphabet, because 256 is not a multiple of 62; masking to six bits and rejecting the two out-of-range draws keeps every character equally likely. The generator is stochastic-rs's SimdRng — the project dogfoods my own RNG library — and it is constructed fresh on each call rather than cached in a thread-local. Constructing one is two splitmix64 steps, which is nothing beside the database round-trip that immediately follows, so keeping an instance alive per thread would buy nothing. A public slug needs a fast, uniform generator, not a cryptographic one, and that is exactly what it gets.
Measuring latency honestly
The number the handler records is the store lookup, not the whole request. That is a measurement decision: I want the latency the backend can actually act on, isolated from the time it cannot — connection setup, TLS, the client's own network. It is worth being explicit that this makes the reported figure service time for the lookup, and that it deliberately excludes any queueing ahead of it. I would rather report a number I can attribute cleanly than a request-total that blends in things outside the process. Samples accumulate into a per-day array, and the same write that appends today's sample prunes the day that just fell out of the retention window:
UPSERT type::record('latency', $day)
SET samples = array::append(samples ?? [], $us);
DELETE type::record('latency', $cutoff);There is no cron pruning old latency data. Retention is a side effect of the hot write — append one day, delete the expired one, in the same statement. Percentiles are computed at read time, by sorting the day's samples and taking the nearest rank:
let n = sorted.len();
let rank = (p / 100.0 * n as f64).ceil() as usize;
let index = rank.saturating_sub(1).min(n - 1);
sorted[index]Percentiles, not an average, because the mean of a latency distribution hides exactly what you care about: a p99 ten times the median is invisible in the average. Sorting per read is O(n log n), which is free at current volume and has an obvious replacement when it stops being free — a fixed-bucket histogram (t-digest or HDR-style) that trades exact ranks for bounded memory and O(1) updates. The array is the version that is correct and trivial today; the histogram is the one you reach for when a day's samples no longer fit comfortably in memory.
Derived state and convergence
The homepage shows how many links exist. That count is derived state, and derived state is where correctness quietly rots. There are three ways to keep it honest, and I have now used all three. Recompute it on every read: always correct, but an O(n) scan for a number you display constantly. Maintain it incrementally in application code: fast, but now every write path that creates or deletes a link has to remember to update the counter, and the day one of them forgets, the number drifts and nothing tells you. Or maintain it in the database, in the same transaction as the mutation that changes it:
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 SurrealDB table event fires inside the transaction of the CREATE or DELETE that triggered it, so the counter cannot be updated late, half-updated, or skipped by a code path that never knew it existed. That removes the whole class of bug — but not quite all of it. A trigger can still be outrun by a schema migration, a bulk import, or a bug in the trigger itself. So the fast incremental path gets a slow, authoritative partner: at startup, the counter is reconciled against the live row count.
UPSERT counter:shortens SET value =
(SELECT VALUE count FROM (SELECT count() FROM url GROUP ALL))[0] ?? 0Now drift survives at most one restart. This is the ordinary shape of self-healing state: a cheap online path for the common case, a periodic reconciliation that recomputes truth from the source of record. The interesting decision is which counters earn that treatment. The shortens count is reconcilable because it is a function of the current rows. The redirect counter deliberately is not derived and is never reconciled — it is cumulative history, it counts clicks on links that have since been deleted, and there is no set of live rows you could recompute it from. Recognising which quantities are recomputable from current state and which are inherently historical is the actual modelling work; the trigger and the reconcile are just the mechanism.
Step back and the same stance appears three times. Latency retention is a write-time side effect. Counter consistency is a transactional trigger plus a startup reconcile. Link expiry is a read-time predicate. None of them is a scheduled job. The system runs essentially no cron, and that is a position rather than an accident: every background sweeper you don't have is a failure mode you don't have to reason about — a job that silently stops, double-runs, or falls behind.
The namespace problem
User slugs live in the top-level path space: shrtn.ink/{slug}. So do the API routes and a set of reserved words. A slug must never shadow a real route, which makes slug allocation a namespace-partitioning problem with two distinct sources of truth — and the mistake is to conflate them.
One source is policy: brand and product words — pricing, blog, about, login — that must not become someone's link for reasons that have nothing to do with routing. That list is hand-maintained, and that is correct; it is a product decision, not a fact about the code.
The other source is the router itself: every path the backend actually serves. Hand-maintaining that list is the trap — it is a second copy of a fact the router already owns, and the day the two disagree, either a valid slug gets rejected or a real route gets shadowed. So it is not hand-maintained. The backend asks the router for its own route table and derives the reserved prefixes from that — the source of truth is the router itself:
register_routes(&mut router);
// every reserved prefix comes straight from the router's own table
let reserved = route_prefixes(
router.routes().iter().map(|r| r.path.as_str()),
);The guard set is therefore a projection of what the router actually serves — it cannot drift, because there is no second copy to drift from. A separate policy test then freezes the top-level namespace: every new endpoint has to go under /api or /me, so a route added next year can never retroactively collide with a slug a user claimed today.