10 min readEngineering

Short Code Generation: Base62 vs Hash vs Random Codes

Short code generation base62 vs hash vs random: exact keyspace math, birthday-bound collision odds, retry patterns, and why sequential codes leak link counts.

Marius Voß
DevRel · edge infra
Short code generation base62 compared with hashing: a pixel keyspace grid beside four ways to mint a short code and their collision behaviour

Short code generation comes down to four options: encode a unique counter in base62, draw random characters, truncate a hash of the URL, or hand out ID ranges from a coordinator. Counters never collide but are guessable. Random codes are not guessable but need a retry path. Hash-and-truncate is the weakest of the four, because it collides sooner than people expect and gives you nothing the others don't.

The numbers decide most of it, so this post works through them. A 7-character base62 code has exactly 3,521,614,606,208 values, and a random one has a 50% chance of at least one collision after roughly 2.2 million links. The first fact makes 7 characters feel enormous. The second is the birthday bound, and it is why "trillions of possibilities" does not mean "no collisions".

If you want the whole system around the code (storage, redirects, caching), start with how to build a URL shortener. This is the zoom-in on one decision from that walkthrough: where the short code comes from.

Four ways to generate a short code: base62 of a counter, random characters, hash and truncate, and counter ranges, with collision and guessability traits for each

Base62 Encoding of an Auto-Increment ID

Base62 encoding converts a number into a string over the 62 symbols 0-9, a-z, A-Z. It is the same idea as hexadecimal with a bigger alphabet, and it is the shortest URL-safe text form of an integer that avoids punctuation. The ID 125 becomes 21 (2 x 62 + 1), and 1,000,000 becomes 4c92.

const ALPHABET =
  "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

export function encode(id: bigint): string {
  if (id === 0n) return ALPHABET[0];
  let out = "";
  while (id > 0n) {
    out = ALPHABET[Number(id % 62n)] + out;
    id /= 62n;
  }
  return out;
}

export function decode(code: string): bigint {
  let id = 0n;
  for (const ch of code) id = id * 62n + BigInt(ALPHABET.indexOf(ch));
  return id;
}

The strength is that uniqueness is inherited from the database. Row 41,000,000 gets one code and nobody else ever gets it. There is no retry loop and no lookup before the insert. Codes also grow slowly: IDs below 62^6 give codes of six characters or fewer, and the first 7-character code appears at ID 56,800,235,584.

The weakness is exposure. The code is the row number in disguise, so decode("4c92") returns 1000000. Anyone can count your links, estimate your growth from two samples a week apart, and iterate every code in order. For an internal tool that is fine. For a public shortener it is a free scraping API, which matters for the open redirect and enumeration risks covered elsewhere on this blog.

You can hide the order without losing uniqueness by passing the ID through an invertible permutation (a small Feistel network is the usual choice) before encoding. Be careful with shortcuts. Multiplying by a constant modulo 62^7 looks scrambled but keeps the last digit incrementing, which a quick test shows. A scrambled counter is obfuscation, not secrecy.

Random Codes: Keyspace, Retries, and the Birthday Bound

A random short code draws each character independently from the alphabet. Use a cryptographic source and an unbiased pick. randomInt(62) in Node does rejection sampling for you, whereas byte % 62 skews the distribution because 256 is not a multiple of 62.

import { randomInt } from "node:crypto";

export function randomCode(length = 7): string {
  let out = "";
  for (let i = 0; i < length; i++) out += ALPHABET[randomInt(62)];
  return out;
}

How long should the code be? The keyspace is 62^length, and the birthday problem says that among n random draws from N values, the chance of at least one repeat is about 1 - e^(-n²/2N). It reaches 50% at roughly sqrt(2N ln 2) draws. The birthday problem is counterintuitive because pairs grow with the square of n.

LengthKeyspace (62^L)Random codes until 50% chance of a repeatChance a new insert collides at 100M links
656,800,235,584about 280,6001 in 568 (0.18%)
73,521,614,606,208about 2,209,5001 in 35,216 (0.0028%)
8218,340,105,584,896about 17,397,8001 in 2,183,401 (0.000046%)

The last column is the number that matters operationally. A repeat among all your links is nearly certain at scale, but what your code actually experiences is a single insert hitting an occupied slot, and that chance is just links / keyspace. At 100 million links and 7 characters, one insert in about 35,000 collides. You will see it in production, so you need a retry path, but it is cheap.

Collision Handling: Let the Database Decide

The pattern that works is insert-then-retry, not check-then-insert. Two requests can both check that aB3x9Qz is free and then both write it. A unique constraint on the code column closes that race, and the failed insert is your signal to draw again.

export async function createWithRetry(
  tryInsert: (code: string) => Promise<boolean>, // false = unique violation
  attempts = 5,
): Promise<string> {
  for (let i = 0; i < attempts; i++) {
    const code = randomCode();
    if (await tryInsert(code)) return code;
  }
  throw new Error("could not allocate a short code");
}

Here tryInsert runs your INSERT and returns false only on a unique-violation error, never on other failures. If a single insert collides with probability p, all attempts fail with probability p^attempts. At the 100M-link, 7-character point, p is 2.84 x 10^-5, so three failures in a row are about 2.3 x 10^-14. Cap the attempts anyway. If you ever see the cap hit, the keyspace is nearly full or the random source is broken, and a loud error beats an infinite loop.

The same discipline applies to idempotency on the create endpoint, because a retried HTTP request must not mint a second link. Rate limits and idempotency covers that half.

Insert-and-retry loop for short codes: draw a random code, insert with a unique constraint, retry on violation up to five attempts, otherwise return the code

Hash and Truncate: Why It Collides Sooner Than You Think

Hashing the URL looks attractive because it is deterministic: the same URL always yields the same code, so you can skip a lookup for duplicates. The cost is that a code only has so many bits to spend. Taking 32 bits of a digest gives 2^32 = 4,294,967,296 values, and the 50% collision point is about 77,163 URLs. Not billions. A 7-character base62 slice (about 41.7 bits) pushes that to roughly 2.2 million, which is the same as a random 7-character code.

import { createHash } from "node:crypto";

export function hashCode(url: string, length = 7): string {
  const digest = createHash("sha256").update(url).digest();
  const n = digest.readBigUInt64BE(0) % 62n ** BigInt(length);
  return encode(n).padStart(length, "0");
}

So truncation does not break the hash. SHA-256 is fine. The collision resistance of the full 256 bits simply does not survive being cut to 41 bits. You inherit random-code behaviour, so you still need the retry path, plus a rule for what to do on a clash (salt and rehash). And determinism cuts against you: two customers shortening the same URL get the same code and therefore the same click stream, unless you mix in an account ID. I'd skip hash-and-truncate in nearly every case. If you want dedupe, look the URL up by a hash column and still generate the code another way.

Counter Ranges and Snowflake-Style IDs

A single auto-increment column becomes a bottleneck when several writers in several regions need IDs. Two patterns avoid that without giving up uniqueness.

The first is counter ranges. A coordinator hands each application instance a block, say 1,000 IDs, and the instance encodes them locally with no round trip per link. If an instance dies, its unused block is simply skipped. Gaps in a code space of trillions are harmless.

The second is a Snowflake-style ID: a timestamp, a machine ID, and a per-millisecond sequence packed into 64 bits. These sort by creation time and need no coordinator. The catch for short links is length. A 64-bit value is up to 18,446,744,073,709,551,615, and since 62^10 = 839,299,365,868,340,224 is smaller than 2^64, it needs up to 11 base62 characters. That is not very short. Snowflake IDs fit database keys better than public codes, so most shorteners keep them internal and use a separate, shorter code.

Both inherit the sequential-guessability problem, because both are ordered by construction. Wrap them in a permutation, or use one of them only as the internal primary key.

Custom Codes and Reserved Words

Vanity slugs are the one place a human chooses the code, and they go through the same unique constraint as everything else. The extra work is validation before the insert. A custom back-half such as /spring-sale has to be checked against three things.

  • Reserved words. Paths your application or the web already uses must never be claimable: api, admin, login, static, robots.txt, favicon.ico, and .well-known. A shortener that lets someone register /login has built a phishing page generator.
  • Collisions with generated codes. If a user takes /aB3x9Qz, your random generator can later produce the same string. The unique constraint handles it, as long as custom and generated codes share one namespace.
  • Case and look-alikes. Base62 is case-sensitive, so /Ab and /ab are different links. Decide whether custom slugs are compared case-insensitively, and consider blocking pairs that differ only by 0/O or l/1. The vanity URLs guide covers the branding side.

Random 7-character codes can also spell something unfortunate. Run generated codes through a short blocklist and redraw on a match. It costs almost nothing.

Enumeration, Guessability, and Privacy

A short code is an address. It is not a secret, and no length turns it into one. Still, the difference between sequential and random is large. With sequential codes every guess hits a live link. With 10 million links spread randomly across 62^7 values, a blind guess hits one with probability 10,000,000 / 3,521,614,606,208, about 1 in 352,000. A scanner needs hundreds of thousands of requests per find, which rate limiting and bot detection can punish.

If a destination must stay private, the code needs to be a capability. That means at least 128 bits of randomness, which in base62 is 22 characters (62^22 is about 2^131; 21 characters give only about 2^125). It also needs a real access check behind it. OWASP's guidance on insecure direct object references makes the same point: unpredictable identifiers help, but authorization is the control. Password-gated or expiring links are for the cases where a leaked code would hurt. The URL shortener security checklist lists the controls to pair with it, and the mechanics of how shorteners work explain why the code alone can never carry trust.

Randomness source matters for the same reason. A seeded, non-cryptographic generator can be predicted from a few outputs, so use the platform's CSPRNG, as in the snippet above. For ready-made alternatives, nanoid implements the unbiased-pick approach with a configurable alphabet and length.

Which Approach to Choose

Pick by what the code has to survive.

  • Internal tool, low volume: base62 of an auto-increment ID. Simple, collision-free, and the guessability does not matter.
  • Public shortener, one database: random 7-character codes with insert-and-retry. I'd start here. The table above shows the collision odds stay tiny for years, and moving to 8 characters later is a one-line change that keeps every existing link valid.
  • Multi-region writes: counter ranges or Snowflake IDs as the internal key, plus a permutation or random code for what the public sees.
  • Hash-and-truncate: only if you need deterministic codes, and then treat it as a random code with a worse retry story.

Whatever you choose, store the code in a unique-indexed column and keep generation off the redirect path, where a two-tier cache is doing the real work (the p95 latency write-up shows what that path looks like when tuned). If you would rather not own code generation, collision handling and reserved-word lists, Elido's API takes a destination and returns a short link, with custom back-halves validated for you. See the plans when you want to try it.

Frequently asked questions

What is base62 encoding in a URL shortener?

Base62 encoding writes a number using 62 symbols: 0-9, a-z and A-Z. A URL shortener takes a unique integer, usually a database ID, and converts it into a compact string like 1Ly7. Because every integer is unique, every base62 code is unique, so there are no collisions to handle.

How many URLs can a 7-character short code hold?

A 7-character base62 code has 62^7 = 3,521,614,606,208 possible values, about 3.5 trillion. At 1,000 new links per second that takes roughly 111 years to exhaust if you assign codes sequentially. Random codes hit their first collisions much sooner, around 2.2 million links for a 50% chance of at least one.

Is hashing a URL a good way to generate a short code?

Usually not. Truncating a hash such as SHA-256 to a short code throws away most of the digest, so different URLs eventually collide, and you still need retry logic. Identical URLs also map to the same code, which stops you from giving two users separate links with separate analytics.

How do you avoid collisions when generating short URLs?

Either make collisions impossible or make them recoverable. Encoding a unique counter in base62 cannot collide. For random or hashed codes, insert with a unique constraint on the code column and retry with a fresh code when the insert fails. Check-then-insert is racy; let the database decide.

Can someone guess or enumerate short links?

Yes, if the codes are sequential. Anyone can walk /1, /2, /3 and read every destination. Random 7-character codes make a blind guess hit a live link about once per 350,000 attempts at 10 million links, which slows scanners but does not make a link private. Treat the code as an address, not a password.

Should short codes be sequential or random?

Use random codes for public links and sequential IDs only internally. Sequential codes are short and collision-free but reveal how many links exist and let competitors scrape them. A random code costs you one unique-constraint retry in rare cases and removes the enumeration problem.

Try Elido

Paste a URL, get a working short link

No signup. Link lives for 30 days. Sign up to keep it forever.

Free, no signup required · 2 per day

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier - no credit card.

Tags
short code generation base62
base62 encoding
url shortener hash collision
birthday problem
random short code
unguessable short links

Continue reading