Post

System Design — URL Shortener with Spring Boot

Design and build a URL shortener from scratch with Spring Boot. Covers Base62 encoding, database schema, redirect handling, click analytics, expiration, and scaling considerations — a classic system design interview question implemented.

System Design — URL Shortener with Spring Boot

The Problem

Every system design interview has a few classics — and URL shortening is right at the top. It sounds simple on the surface: take a long URL, give back a short one, redirect when someone visits it. But once you start thinking about scale, collisions, analytics, and expiration, the depth reveals itself quickly.

In this post, we’ll design and implement a URL shortener from scratch using Spring Boot 3.5 and Java 21. We won’t stop at a toy demo — we’ll cover the encoding strategy, database schema, redirect semantics, click tracking, expiration logic, and how you’d scale this for millions of URLs.


Requirements

Functional Requirements

#Requirement
1Given a long URL, generate a unique short URL
2Redirect short URL to the original URL
3Optional expiration time for URLs
4Click analytics (count per short URL)
5Short URLs should be as short as possible

Non-Functional Requirements

#Requirement
1Low latency redirects (< 10ms for cache hit)
2High availability — redirects must always work
3Read-heavy traffic (100:1 read/write ratio)
4Short codes must be unique — no collisions
5URLs are not guessable (sequential IDs are acceptable for internal use)

High-Level Design

Shorten Flow

sequenceDiagram
    participant Client
    participant API as URL Controller
    participant Service as URL Service
    participant Encoder as Base62 Encoder
    participant DB as PostgreSQL

    Client->>API: POST /api/shorten {url, expiresInDays}
    API->>Service: shorten(request)
    Service->>DB: INSERT url_mapping (get auto-generated ID)
    DB-->>Service: id = 12345
    Service->>Encoder: encode(12345)
    Encoder-->>Service: "3d7"
    Service->>DB: UPDATE short_code = "3d7"
    Service-->>API: {shortUrl, originalUrl, expiresAt}
    API-->>Client: 201 Created

diagram

Redirect Flow

sequenceDiagram
    participant Browser
    participant API as URL Controller
    participant Service as URL Service
    participant DB as PostgreSQL

    Browser->>API: GET /3d7
    API->>Service: resolve("3d7")
    Service->>DB: SELECT WHERE short_code = "3d7"
    DB-->>Service: originalUrl
    Service->>DB: UPDATE click_count + 1
    Service-->>API: "https://example.com/long-url"
    API-->>Browser: 302 Found (Location: original URL)

diagram


Encoding Strategy — Base62 from Auto-Increment ID

The encoding strategy is the heart of any URL shortener. Here are the common options:

StrategyProsCons
Random hash (MD5/SHA)Not guessableCollisions possible, need retry logic
UUIDGlobally uniqueToo long (36 chars)
Counter + Base62Short, no collisionsSequential/predictable
Nano IDShort, randomCollision possible at scale

We chose Base62 encoding from auto-increment ID because:

  1. Zero collisions — each ID is unique by database guarantee
  2. Short output — 6 characters can encode 56 billion URLs (62^6)
  3. Deterministic — same ID always maps to same code
  4. Simple — no retry logic, no distributed coordination

Base62 Alphabet

1
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

Length vs Capacity

CharactersPossible URLs
162
23,844
3238,328
414.7 million
5916 million
656.8 billion
73.5 trillion

Database Schema

1
2
3
4
5
6
7
8
9
10
CREATE TABLE url_mappings (
    id          BIGSERIAL PRIMARY KEY,
    short_code  VARCHAR(10) NOT NULL UNIQUE,
    original_url VARCHAR(2048) NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMP,
    click_count BIGINT NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX idx_short_code ON url_mappings(short_code);

The unique index on short_code is critical — every redirect performs a lookup by this column, so it must be indexed.


Implementation

Base62 Encoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class Base62Encoder {

    private static final String ALPHABET =
        "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    private static final int BASE = ALPHABET.length();

    public String encode(long value) {
        if (value == 0) {
            return String.valueOf(ALPHABET.charAt(0));
        }
        StringBuilder sb = new StringBuilder();
        while (value > 0) {
            sb.append(ALPHABET.charAt((int) (value % BASE)));
            value /= BASE;
        }
        return sb.reverse().toString();
    }

    public long decode(String encoded) {
        long result = 0;
        for (char c : encoded.toCharArray()) {
            result = result * BASE + ALPHABET.indexOf(c);
        }
        return result;
    }
}

The algorithm is straightforward: repeatedly divide by 62 and map the remainder to our alphabet. Reversing at the end gives us the most significant digit first.

URL Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Service
public class UrlService {

    private final UrlRepository urlRepository;
    private final Base62Encoder base62Encoder;
    private final String baseUrl;

    @Transactional
    public ShortenResponse shorten(ShortenRequest request) {
        UrlMapping mapping = new UrlMapping();
        mapping.setOriginalUrl(request.url());
        mapping.setShortCode("temp");

        if (request.expiresInDays() != null && request.expiresInDays() > 0) {
            mapping.setExpiresAt(LocalDateTime.now().plusDays(request.expiresInDays()));
        }

        // Save to get auto-generated ID
        mapping = urlRepository.save(mapping);

        // Encode ID to Base62
        String shortCode = base62Encoder.encode(mapping.getId());
        mapping.setShortCode(shortCode);
        mapping = urlRepository.save(mapping);

        return new ShortenResponse(
            baseUrl + "/" + shortCode,
            mapping.getOriginalUrl(),
            mapping.getExpiresAt()
        );
    }

    @Transactional
    public String resolve(String shortCode) {
        UrlMapping mapping = urlRepository.findByShortCode(shortCode)
            .orElseThrow(() -> new UrlNotFoundException("Short URL not found: " + shortCode));

        if (mapping.getExpiresAt() != null
                && mapping.getExpiresAt().isBefore(LocalDateTime.now())) {
            throw new UrlNotFoundException("Short URL has expired: " + shortCode);
        }

        urlRepository.incrementClickCount(shortCode);
        return mapping.getOriginalUrl();
    }
}

Key design decision: we save twice — first to get the auto-generated ID, then to update with the encoded short code. This guarantees uniqueness without any retry logic.

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RestController
public class UrlController {

    private final UrlService urlService;

    @PostMapping("/api/shorten")
    public ResponseEntity<ShortenResponse> shorten(@Valid @RequestBody ShortenRequest request) {
        ShortenResponse response = urlService.shorten(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{code}")
    public ResponseEntity<Void> redirect(@PathVariable String code) {
        String originalUrl = urlService.resolve(code);
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create(originalUrl))
                .build();
    }

    @GetMapping("/api/stats/{code}")
    public ResponseEntity<UrlMapping> stats(@PathVariable String code) {
        UrlMapping mapping = urlService.getStats(code);
        return ResponseEntity.ok(mapping);
    }
}

Redirect: 302 vs 301

This is a subtle but critical design decision:

Status CodeMeaningBrowser Caches?Analytics Impact
301Moved PermanentlyYes — browser won’t ask againLose click tracking
302Found (Temporary)No — browser asks every timeEvery click is tracked

We use 302 Found because:

  1. Click analytics — every request hits our server, so we can count clicks
  2. Expiration — if a URL expires, a 301 would still redirect from browser cache
  3. Flexibility — we can change where a short code points without users clearing cache

Use 301 only if you don’t need analytics and want to reduce server load.


Click Analytics

Click tracking is implemented with an atomic UPDATE query:

1
2
3
@Modifying
@Query("UPDATE UrlMapping u SET u.clickCount = u.clickCount + 1 WHERE u.shortCode = :shortCode")
void incrementClickCount(@Param("shortCode") String shortCode);

This is a single atomic operation — no race conditions even under concurrent access. For higher throughput at scale, you could batch click updates using a write-behind cache or event stream.


URL Expiration

Expiration is checked at read time rather than using a background cleanup job:

1
2
3
if (mapping.getExpiresAt() != null && mapping.getExpiresAt().isBefore(LocalDateTime.now())) {
    throw new UrlNotFoundException("Short URL has expired: " + shortCode);
}

This is simple and effective. For cleanup of expired records (to reclaim storage), you can add a scheduled job:

1
2
3
4
@Scheduled(cron = "0 0 2 * * ?") // 2 AM daily
public void purgeExpiredUrls() {
    urlRepository.deleteByExpiresAtBefore(LocalDateTime.now());
}

Scaling Considerations

Read-Heavy Workload

URL shorteners are massively read-heavy. For every URL created, it might be accessed thousands of times. This calls for aggressive caching.

graph LR
    A[Browser] --> B[Load Balancer]
    B --> C[App Server 1]
    B --> D[App Server 2]
    B --> E[App Server N]
    C --> F[Redis Cache]
    D --> F
    E --> F
    F --> G[PostgreSQL Primary]
    G --> H[Read Replica 1]
    G --> I[Read Replica 2]

diagram

Caching Strategy with Redis

1
2
3
4
@Cacheable(value = "urls", key = "#shortCode")
public String resolve(String shortCode) {
    // DB lookup only on cache miss
}

Cache hit ratio for URL shorteners is typically > 95% since popular links are accessed repeatedly.

Sharding by Short Code

For billions of URLs, a single database won’t suffice. Shard by the first character of the short code:

ShardShort Codes Starting With
00-9
1A-M
2N-Z
3a-m
4n-z

This gives roughly even distribution with Base62 encoding.


Collision Handling

With our Base62-from-ID approach, collisions are impossible by design. Each database row gets a unique auto-increment ID, and the encoding is deterministic.

If you chose a random generation approach instead, you’d need:

1
2
3
4
String shortCode;
do {
    shortCode = generateRandom(7);
} while (urlRepository.existsByShortCode(shortCode));

The probability of collision with 7-character random strings:

  • After 1 million URLs: ~0.00003%
  • After 100 million URLs: ~0.3%
  • After 1 billion URLs: ~28%

This is why we prefer the ID-based approach — zero collisions, zero retries.


Common Problems

ProblemCauseSolution
Short code collisionRandom generation at scaleUse ID-based Base62 encoding
Lost click counts301 redirect cachingUse 302 redirect
Expired URL still redirectsBrowser cached 301Use 302 + check expiry on every request
Slow redirectsNo caching, DB lookup every timeAdd Redis cache layer
Sequential codes guessableBase62 from sequential IDAccept trade-off or add offset/salt
Hot partitionsUneven access patternsCache popular URLs in memory
Database growthNever cleaning expired URLsScheduled purge job

Full Working Example

The complete source code is available on GitHub:

spring-boot-url-shortener

To run locally:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Start PostgreSQL
docker compose up -d

# Run the application
./mvnw spring-boot:run

# Shorten a URL
curl -X POST http://localhost:8080/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very-long-article-url", "expiresInDays": 30}'

# Response:
# {
#   "shortUrl": "http://localhost:8080/1",
#   "originalUrl": "https://example.com/very-long-article-url",
#   "expiresAt": "2026-09-21T10:30:00"
# }

# Redirect
curl -v http://localhost:8080/1
# < HTTP/1.1 302 Found
# < Location: https://example.com/very-long-article-url

# Check stats
curl http://localhost:8080/api/stats/1
# {
#   "id": 1,
#   "shortCode": "1",
#   "originalUrl": "https://example.com/very-long-article-url",
#   "clickCount": 1,
#   "createdAt": "2026-08-22T10:30:00",
#   "expiresAt": "2026-09-21T10:30:00"
# }

Interview Tips

When discussing this in a system design interview, emphasize:

  1. Start with requirements — clarify read/write ratio, scale, features
  2. Justify your encoding choice — explain trade-offs between random vs ID-based
  3. Mention 302 vs 301 — shows you understand HTTP semantics and their implications
  4. Discuss scaling — caching, sharding, read replicas
  5. Think about edge cases — expiration, hot URLs, analytics accuracy

References

This post is licensed under CC BY 4.0 by the author.