Building a URL shortener that never guesses a code
Why we encode the primary key instead of generating random short codes, and what that decision removed from the system.

Jun 17, 2026 · 8 min read
Every re-engagement message we send a loan applicant carries a link back into their journey. The system that builds those links produces long, ugly URLs — a path, a redirect flag, and a doubly base64-encoded payload carrying journey IDs and routing hints. That is fine for a browser to follow but hostile to an SMS body, where characters are scarce and a wrapped link looks like spam. So we needed to shorten them.
The shortener itself is unremarkable. The decision worth writing about is how a short code comes to exist, because the obvious way to do it quietly builds a problem into your system, and we wanted to avoid carrying that problem at all.
Why this mattered
Ask most engineers to design a URL shortener and you'll hear the same shape of answer: generate a random string, check whether it already exists, regenerate if it collides, then save. It's a reasonable instinct and it works fine in a demo. But look closely at what that loop commits you to. Every single write now depends on a read first. Under contention — exactly when your service is busiest — the probability of a collision rises, the retry count climbs, and the write path gets slower precisely when you can least afford it. You've also signed up to maintain a uniqueness index on the code column forever, and to reason about what happens when the random generator and the database disagree.
None of that complexity buys anything a loan applicant cares about. It exists only because we chose to invent an identifier that the database was already prepared to give us for free. That reframing is the whole post: a short code is just an identifier, and we already have a perfect one.
The collision loop isn't solving a hard problem. It's creating one, then solving it.
The approach
Every row we insert gets a unique, sequential primary key from Postgres. That key is already collision-free by construction — the database guarantees it. The only reason we don't hand it to users directly is cosmetic: loannetwork.app/s/1 and /s/2 are ugly and leak how many links we've made. So instead of generating a new identifier, we encode the one we have. We run the primary key through Hashids with a salt and a four-character minimum length, and the result is the short code. Decoding reverses it back to the key.
This collapses the entire creation flow. There is no "check if it exists" step, because encoding is one-to-one with a key that is unique by definition. There is no retry loop, because there is nothing to collide with. We don't even store the code — it's recomputed from the key whenever we need it, which means there's one less column to keep consistent. The only uniqueness we still enforce is on the original URL itself, so that shortening the same link twice returns the same code instead of minting duplicates. Creating a short URL became, almost literally, "insert the row, encode the ID it got back."
defp encode_id(id), do: Hashids.encode(hashids(), id)
defp decode_id(code), do: Hashids.decode(hashids(), code)That's the core of it. Everything else in the module is plumbing around those two functions.
Obfuscation is not encryption
Hashids makes IDs non-obvious; it does not make them secret. Someone determined enough can recover the scheme and walk the space of codes. We accepted that deliberately — a short link resolves to a public path, not to private data — but it's exactly why we would never reach for this pattern to protect anything that has to be unguessable. The right tool changes the moment the identifier needs to be a secret.
The other decisions are about the read path, because resolving a code happens on every click and needs to feel instant. We read through Redis with a plain cache-aside pattern: check the cache, and on a miss, fall back to Postgres and populate the cache with a twelve-hour TTL. The detail we think is non-obvious is what creation does not do — it never warms the cache. We only write to Redis on the first actual read.
The reasoning is about our traffic shape. A large fraction of the links we mint are clicked once or never, because the applicant they were sent to never returns. Eagerly caching every created link would fill Redis with entries that nobody ever requests, paying memory for links that will never be read. Caching on first read instead means the cache fills with exactly the links that get used, and the only cost is that the very first click on a fresh link pays one database round trip — a trip that also seeds the cache for the next twelve hours. We traded a guaranteed write-time cost for an occasional, self-amortizing read-time one, and for our access pattern that's clearly the better trade.
Click tracking follows the same "don't make the user wait" principle. Recording that a link was clicked is useful for analytics but irrelevant to the redirect, so we hand it to a supervised background task and send the redirect immediately. The user's browser is moving before we've written the click row, and if that write fails, the redirect is entirely unaffected.
Why it's fast
The redirect is fast not because of one clever optimization but because the design quietly removed almost everything a request could wait on. It's worth walking the hot path and noticing what isn't there.
Start with decoding. Turning a short code back into a database key is pure arithmetic — Hashids runs entirely in memory on the CPU, with no lookup table and no network call. A random-code scheme would have to ask the database "which row owns this code?" before it could do anything; our scheme already knows the key the moment it decodes the string. We've replaced a query with a few microseconds of math, and that difference lands on every single click.
Then the cache. On a warm link the entire resolution is one Redis round trip and nothing else — no Postgres, no joins, no ORM materialization. Postgres is touched only on the first click of a fresh link, and that one trip seeds a twelve-hour cache entry, so the database sees roughly one read per link per half-day no matter how viral the link goes. The common case is the cheap case, which is the property you actually want from a cache.
The Redis access is itself bounded so it can't become the slow part under load. Connections are pooled with poolboy rather than opened per request, so a burst of clicks reuses a small set of warm connections instead of paying TCP setup over and over. And every command carries a hard five-second ceiling, so a momentarily slow cache degrades into a fallback rather than a pile-up of requests blocked indefinitely.
def q(args) do
:poolboy.transaction(:redis_pool, fn worker -> :eredis.q(worker, args, 5000) end)
endFinally, the work that doesn't block. The click write is fired into a supervised task and the 302 goes out immediately, so analytics never sits on the critical path. The response body is empty — a 302 with a location header carries no payload to serialize or transfer. And because all of this runs on the BEAM, a slow click write or a stalled Redis call is one lightweight process getting out of the way, not a thread tied up starving everything behind it.
Fast here isn't the result of optimizing the work. It's the result of arranging the system so most of the work never happens on the request.
What we learned
Encoding the primary key turned a write-with-retry into a plain insert, and the absence is the point: there's no collision loop to reason about, no second index to maintain, no failure mode that only appears under load. The cheapest code to operate is the code you never wrote, and choosing the right identifier let us not write a whole category of it.
The honest costs are two, and we'd name both to anyone copying this. Codes are enumerable, which we chose to accept. And cache-on-read means the first hit on any link is slower than the rest — fine here, potentially wrong for a workload where every link is hot immediately.
There's also a small piece of debt we'll own: the redirect path still carries a couple of leftover debug log statements from chasing a proxy-header bug behind our load balancer. They're harmless and trivially removable, but they're a fair reminder that the part of this system most likely to surprise us next isn't the elegant Hashids trick — it's the unglamorous business of figuring out the correct host to redirect to when you're sitting behind a proxy.

Written by
Ratnesh MauryaSoftware Engineer
Backend-focused engineer working on Nestara's distributed systems and data pipelines. Drawn to hard problems in service reliability, loan decisioning latency, and the architecture choices that compound over time.
Keep reading
An idempotent daily backfill that's safe to run twice
A backfill that isn't safe to re-run is a liability you put on a cron. Here's how we made every step a no-op the second time.

Sanskar Soni
Jun 17, 2026
At-least-once webhooks with a one-row claim table
How a single unique constraint turns a noisy, duplicate-prone event stream into exactly-one webhook delivery per lead.

Sanskar Soni
Jun 17, 2026
One active notification per lead: taming event storms with a partial unique index
A loan journey emits events faster than nudges can fire. Here's how we guarantee an applicant only ever gets the right one.

Ratnesh Maurya
Jun 17, 2026