Definition
- Traffic shape – Creates are rare, redirects are extremely frequent, so the redirect path must be cache-first and sub-millisecond.
- Code generation – A monotonic counter encoded in base62 guarantees uniqueness without collisions; hashing truncates a URL hash but requires collision detection.
- Cache layer – A Redis cache in front of the database absorbs the vast majority of reads.
- 301 vs 302 – 301 (permanent) lets browsers cache and skip your server, losing per-click analytics; 302 (temporary) brings every click back for counting.
- Analytics – Click events are recorded off the redirect path asynchronously to keep the redirect fast.
Imagine National Bank of India gives every long account reference a short, memorable locker number. When Rajesh quotes the short number, a clerk instantly looks it up. The bank must make sure two accounts never get the same number. It keeps the busiest lockers' mappings on a fast notepad at the front desk because the same few are asked for constantly. And it decides whether to tell customers the number is permanent or temporary, which changes how often they come back.
Understanding the Concepts
What is a URL shortener and what is its traffic shape?
A URL shortener maps a long URL to a short, unique code and redirects any request for that code back to the original URL. There are two operations: create (rare) and redirect (extremely frequent). The system is therefore read-heavy by a huge ratio, so the redirect path must be as cheap as possible.
package com.bank.shortener.model;
import java.time.Instant;
public class UrlMapping {
private String shortCode; // e.g. "aB3xK9"
private String longUrl; // e.g. "https://nbi.com/offers/home-loan?campaign=2024"
private String owner; // e.g. "marketing-team"
private Instant createdAt;
private Instant expiresAt;
private long clickCount;
// Constructor and getters omitted for brevity
}
How does counter plus base62 code generation work?
Take a globally unique, ever-increasing number and encode it in base62 (digits 0-9, A-Z, a-z). Uniqueness is guaranteed by construction, so there are zero collisions.
package com.bank.shortener.service;
public class Base62Encoder {
private static final String ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
public static String encode(long number) {
if (number == 0) return "0";
StringBuilder sb = new StringBuilder();
while (number > 0) {
sb.append(ALPHABET.charAt((int) (number % 62)));
number /= 62;
}
return sb.reverse().toString();
}
public static long decode(String code) {
long result = 0;
for (char c : code.toCharArray()) {
result = result * 62 + ALPHABET.indexOf(c);
}
return result;
}
public static void main(String[] args) {
long id = 123456789L;
String code = encode(id);
System.out.println("Encoded: " + code);
System.out.println("Decoded: " + decode(code));
}
}
Encoded: 8M0kX
Decoded: 123456789
How does range allocation remove the central bottleneck?
Each application server fetches a block of counter values from a central allocator, then mints codes locally from that block without any network call. When the block is exhausted, it fetches the next one.
package com.bank.shortener.service;
import java.util.concurrent.atomic.AtomicLong;
public class RangeAllocator {
private final AtomicLong globalCounter = new AtomicLong(1_000_000);
private static final long RANGE_SIZE = 10_000;
public long[] allocateRange() {
long start = globalCounter.getAndAdd(RANGE_SIZE);
return new long[] { start, start + RANGE_SIZE - 1 };
}
}
// Each server instance:
// long[] range = allocator.allocateRange(); // [1000000, 1009999]
// Mint codes from range[0] to range[1] locally.
// When exhausted, fetch the next range.
How does hashing compare to counter-based generation?
| Aspect | Counter + Base62 | Hash + Truncate |
|---|---|---|
| Uniqueness | Guaranteed, zero collisions | Collisions possible, needs detection |
| Same URL = same code | No (unless you check) | Yes (by construction) |
| Central dependency | Range allocator (infrequent) | None |
| Complexity | Simple | Collision retry logic needed |
package com.bank.shortener.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public class HashBasedGenerator {
public static String generateCode(String longUrl) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(longUrl.getBytes(StandardCharsets.UTF_8));
// Take first 7 chars of base62-encoded hash
long value = Math.abs(java.nio.ByteBuffer.wrap(hash).getLong());
String code = Base62Encoder.encode(value);
return code.substring(0, Math.min(7, code.length()));
}
// PROBLEM: two different URLs can produce the same 7-char code.
// Must check DB and retry with a salt on collision.
}
How does the cache-first redirect path work?
The same popular short codes are requested constantly. A Redis cache absorbs the majority of reads.
package com.bank.shortener.service;
public class RedirectService {
private final RedisCache cache;
private final UrlMappingRepository repository;
public RedirectService(RedisCache cache, UrlMappingRepository repository) {
this.cache = cache;
this.repository = repository;
}
public String resolve(String shortCode) {
// 1. Check cache first (sub-millisecond)
String longUrl = cache.get(shortCode);
if (longUrl != null) {
return longUrl;
}
// 2. Cache miss: read from DB, populate cache
longUrl = repository.findByCode(shortCode);
if (longUrl != null) {
cache.set(shortCode, longUrl, 3600); // TTL 1 hour
}
return longUrl;
}
}
What is the difference between 301 and 302 redirects?
HTTP 301 (permanent) tells browsers to cache the mapping. Future clicks skip your server, which is fastest but loses per-click analytics. HTTP 302 (temporary) brings every click back, enabling counting and re-pointing.
package com.bank.shortener.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.net.URI;
public class RedirectController {
private final RedirectService redirectService;
private final AnalyticsPublisher analyticsPublisher;
// Use 302 for campaign links (need analytics)
public ResponseEntity<Void> redirect(String shortCode) {
String longUrl = redirectService.resolve(shortCode);
if (longUrl == null) {
return ResponseEntity.notFound().build();
}
// Record click asynchronously (off the hot path)
analyticsPublisher.recordClick(shortCode);
// 302 = every click comes back to us for counting
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(longUrl))
.build();
}
}
How do you handle custom aliases and analytics?
Custom aliases go through the same uniqueness constraint. Analytics are recorded off the hot path by emitting a click event to Kafka.
package com.bank.shortener.service;
public class CustomAliasService {
private final UrlMappingRepository repository;
public String createCustomAlias(String alias, String longUrl) {
if (repository.existsByCode(alias)) {
throw new IllegalArgumentException("Alias already taken: " + alias);
}
// Validate against reserved words: "api", "admin", "health"
if (isReserved(alias)) {
throw new IllegalArgumentException("Reserved word: " + alias);
}
repository.save(alias, longUrl);
return alias;
}
private boolean isReserved(String alias) {
return java.util.Set.of("api", "admin", "health").contains(alias);
}
}
How do you scale the key space?
Base62 with 7 characters gives 62 to the power of 7, which is about 3.5 trillion codes. Size the length to exceed projected volume with headroom. Scale throughput by keeping services stateless, sharding the mapping store by code, and relying heavily on the cache.
# Key space calculation:
# 62^5 = 916,132,832 (~1 billion)
# 62^6 = 56,800,235,584 (~57 billion)
# 62^7 = 3,521,614,606,208 (~3.5 trillion)
# For 1 billion URLs, 7 characters is more than enough.
What are common interview follow-up questions?
How would you handle URL expiry and reclaim codes? How do you prevent abuse (someone creating millions of short URLs)? How would you implement A/B testing with different redirect destinations? What happens when the cache goes down? How would you migrate from 6-character codes to 7-character codes without breaking existing links?
Visual Overview
┌─────────────────────────────────────────────────────┐ │ URL SHORTENER ARCHITECTURE │ ├─────────────────────────────────────────────────────┤ │ │ │ CREATE FLOW: │ │ Client ──POST /shorten──▶ API Server │ │ │ │ │ ┌─────────▼──────────┐ │ │ │ Range Allocator │ │ │ │ (get counter block)│ │ │ └─────────┬──────────┘ │ │ │ │ │ ┌─────────▼──────────┐ │ │ │ Base62 encode │ │ │ │ counter → shortCode│ │ │ └─────────┬──────────┘ │ │ │ │ │ ┌─────────▼──────────┐ │ │ │ DB: save mapping │ │ │ └────────────────────┘ │ │ │ │ REDIRECT FLOW: │ │ Client ──GET /aB3xK9──▶ API Server │ │ │ │ │ ┌─────────▼──────────┐ │ │ │ Redis Cache │ │ │ │ (hit? → redirect) │ │ │ └───┬───────────┬────┘ │ │ hit │ │ miss │ │ ┌───▼───┐ ┌────▼─────┐ │ │ │302/301│ │ DB lookup │ │ │ │redirect│ │+ fill │ │ │ └───────┘ │ cache │ │ │ └──────────┘ │ │ │ │ ANALYTICS (async): │ │ Click event ──▶ Kafka ──▶ Analytics Consumer │ └─────────────────────────────────────────────────────┘
Key Takeaway
A URL shortener is fundamentally a write-rarely, read-constantly key-value system. At National Bank of India, Pankaj's marketing team needed to shorten long statement and campaign URLs sent to millions of customers like Rajesh. The entire design follows from the read-heavy traffic shape: optimize the redirect to serve it from cache, push everything non-essential off that path, and pick 301 versus 302 deliberately.
The central decision is short-code generation. Counter plus base62 guarantees uniqueness by construction because each code comes from a unique counter value, and range allocation gives each server a block so codes are minted locally. Hashing the URL truncated to a few characters can collide and requires a detect-and-retry loop, so it is only attractive when identical URLs must share a code. The redirect path is served cache-first from Redis: a hit returns sub-millisecond without touching the database, and a miss fills the cache for next time.
The 301-versus-302 choice silently decides whether the team can see their traffic. Pankaj used 302 for campaign links so every click came back to the server for analytics and the destination could be changed mid-campaign, and 301 for permanent internal links where analytics were not needed. Click events were emitted to Kafka on the redirect path and aggregated by a separate consumer, so counting never slowed the redirect.
Remember one sentence: Generate collision-free codes with a counter in base62 using per-server range allocation, serve the read-heavy redirect from a cache-aside layer, choose 302 for analytics and 301 for permanent links, and record clicks off the hot path.
---