Building a URL Shortener Design System with Spring Boot 4 and Redis

In this post, we’ll build a URL Shortener Design System using Spring Boot 4 and Redis, focusing on scalability, performance, and clean system design.


Prerequisites

This is the list of all the prerequisites:

  • Spring Boot 4 or later
  • Maven 3.9+
  • Java 21 or later
  • Docker and Docker Compose
  • IntelliJ IDEA, Visual Studio Code, or another IDE
  • Postman / insomnia or any other API testing tool.
  • Redis 7.4+

Overview

URL shorteners are deceptively simple on the surface. Under real-world load they expose every weak architectural decision you made. A service like bit.ly processes billions of redirects per day — nearly all reads, a small fraction of writes. That asymmetry drives every decision in this article.

We’ll build a production-ready URL shortener using Java 21, Spring Boot 4, PostgreSQL 16, and Redis 7. The infrastructure spins up with a single Docker Compose command. By the end you’ll have a working service with cache-aside caching, async click tracking, Base62 encoding, Flyway-managed migrations, and full test coverage.

What is an URL Shortener?

A URL shortener is a service that converts long URLs into compact, easy-to-share aliases. When users access the short URL, they are redirected to the original destination.

Why Build a URL Shortener?

A URL shortener system teaches several important backend concepts:

  • High-throughput API design
  • Key-value storage modeling
  • Cache-first architectures
  • Collision handling in ID generation
  • Expiration and lifecycle management
  • Horizontal scalability

Famous examples include bit.ly and TinyURL, which handle billions of redirects efficiently.

System Design

Before writing any code, it pays to understand the read/write profile:

  • Write rate: users shortening URLs — maybe 1% of total traffic
  • Read rate: users clicking shortened links — the other 99%

That 99:1 read-to-write ratio makes Redis caching load-bearing infrastructure, not a nice-to-have. Without it, every redirect hits PostgreSQL, and the database becomes the bottleneck within seconds of traffic picking up.

Architecture Overview

┌────────────────────────────────────────────────┐
│ HTTP Client │
└────────────────────┬───────────────────────────┘

┌───────────▼───────────┐
│ Spring Boot 4 API │
│ │
│ ┌─────────────────┐ │
│ │ RedirectController││
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │UrlShortenerService││
│ └────────┬────────┘ │
└───────────┼───────────┘

┌──────────────┼──────────────┐
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌───▼──────────────┐
│ Redis 7 │ │ PostgreSQL │ │ ClickTracking │
│ (cache) │ │ (primary) │ │ (async executor) │
└─────────┘ └─────────────┘ └──────────────────┘

Redirect Flow

Every GET request hits Redis first. PostgreSQL is the fallback only on a cache miss:

GET /{shortCode}


Redis lookup

┌────┴──────────────────┐
│ HIT │ MISS
▼ ▼
Return URL PostgreSQL lookup
+ async click + cache in Redis
tracking + async click tracking
+ return URL

This gives sub-5ms redirects for cached URLs and 10–20ms for cold cache misses.

Short Code Generation Strategy

Three common approaches exist:

Base62 encoding of sequential IDs wins on all criteria. The database sequence guarantees uniqueness without collision checks. Base62 uses [0-9A-Za-z] — 62 characters. With 7 characters that’s 62⁷ ≈ 3.5 trillion unique codes. The short code is compact, URL-safe, and decodes back to the original ID if needed.

ID: 1      → "1"
ID: 62 → "10"
ID: 1000 → "G8"
ID: 100000 → "q0U"

Database Schema

The data model is minimal by design. URL shorteners don’t need complex schemas.

CREATE SEQUENCE url_mapping_seq
START WITH 1
INCREMENT BY 1
CACHE 1;

CREATE TABLE url_mappings
(
id BIGINT PRIMARY KEY DEFAULT nextval('url_mapping_seq'),
short_code VARCHAR(10) NOT NULL UNIQUE,
original_url VARCHAR(2048) NOT NULL,
click_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
expires_at TIMESTAMP WITH TIME ZONE
);

CREATE INDEX idx_url_mappings_short_code ON url_mappings (short_code);

The index on short_code is the most important line in this schema. Every redirect resolves through it. Without it, every GET is a sequential scan.

ERD:

┌──────────────────────────────────────┐
│ url_mappings │
├──────────────────────────────────────┤
│ id BIGINT (PK, sequence) │
│ short_code VARCHAR(10) UNIQUE INDEX │
│ original_url VARCHAR(2048) │
│ click_count BIGINT DEFAULT 0 │
│ created_at TIMESTAMPTZ │
│ expires_at TIMESTAMPTZ (nullable) │
└──────────────────────────────────────┘

expires_at is included for future TTL-based link expiration without a schema migration.

Let’s code

Infrastructure with Docker Compose

Spin up Redis and PostgreSQL with health checks:

services:
postgres:
image: postgres:16-alpine
container_name: urlshortener-postgres
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: urluser
POSTGRES_PASSWORD: urlpass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urluser -d urlshortener"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s

redis:
image: redis:7-alpine
container_name: urlshortener-redis
ports:
- "6379:6379"
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--save ""
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5

volumes:
postgres_data:

Two Redis configuration choices to call out:

  • --maxmemory-policy allkeys-lru evicts the least recently used keys when memory fills up. For a URL shortener where old URLs are rarely clicked, this is the correct policy. URLs that get traffic stay hot; stale ones get evicted.
  • --save "" disables persistence. Redis here is a cache, not a database. Losing cached data on restart is acceptable — the source of truth is PostgreSQL.

Start the stack:

docker compose up -d

Spring Boot project

We’ll start by creating a simple Spring Boot project from start.spring.io.

Maven Dependencies

    <parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>

<properties>
<java.version>21</java.version>
</properties>

<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- JPA + PostgreSQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

<!-- Redis (Lettuce) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Flyway -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

Application Configuration

spring:
datasource:
url: jdbc:postgresql://localhost:5432/urlshortener
username: urluser
password: urlpass
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000

jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect

flyway:
enabled: true
locations: classpath:db/migration

data:
redis:
host: localhost
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 1000ms

url-shortener:
base-url: http://localhost:8080
cache-ttl-hours: 24

ddl-auto: validate is the safe production setting — Hibernate validates the schema against the entities but never modifies the database. Flyway owns schema changes.

The Lettuce pool (max-active: 16) handles concurrent redirect traffic without saturating Redis connections.

Implementation

Project Structure

src/main/java/com/boottechsolutions/urlshortener/
├── UrlShortenerApplication.java
├── config/
│ ├── AsyncConfig.java
│ ├── RedisConfig.java
│ └── UrlShortenerProperties.java
├── controller/
│ ├── RedirectController.java
│ └── UrlController.java
├── domain/
│ └── UrlMapping.java
├── dto/
│ ├── ErrorResponse.java
│ ├── ShortenRequest.java
│ ├── ShortenResponse.java
│ └── UrlStatsResponse.java
├── exception/
│ ├── GlobalExceptionHandler.java
│ └── UrlNotFoundException.java
├── repository/
│ └── UrlMappingRepository.java
├── service/
│ ├── ClickTrackingService.java
│ └── UrlShortenerService.java
└── util/
└── Base62Encoder.java

Configuration Properties

Using @ConfigurationProperties with a Java 21 record is the idiomatic Spring Boot 4 pattern:

@ConfigurationProperties(prefix = "url-shortener")
public record UrlShortenerProperties(
String baseUrl,
long cacheTtlHours
) {}

Enable scanning in the main class:

@SpringBootApplication
@ConfigurationPropertiesScan
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}

Redis Configuration

@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

StringRedisSerializer serializer = new StringRedisSerializer();
template.setKeySerializer(serializer);
template.setValueSerializer(serializer);
template.setHashKeySerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();

return template;
}
}

Both keys and values are strings. StringRedisSerializer avoids the byte-prefix that the default JdkSerializationRedisSerializer adds — keeping the Redis data human-readable for debugging.

Async Configuration

Click tracking must not block redirects. A dedicated thread pool isolates click tracking from request handling:

@Configuration
@EnableAsync
public class AsyncConfig {

@Bean(name = "clickTrackingExecutor")
public Executor clickTrackingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("click-tracking-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);
executor.initialize();
return executor;
}
}

setWaitForTasksToCompleteOnShutdown(true) with a 10-second termination window prevents losing queued click events during application shutdown.

Domain Entity

@Entity
@Table(
name = "url_mappings",
indexes = @Index(name = "idx_url_mappings_short_code", columnList = "short_code", unique = true)
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UrlMapping {

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "url_mapping_seq")
@SequenceGenerator(name = "url_mapping_seq", sequenceName = "url_mapping_seq", allocationSize = 1)
private Long id;

@Column(name = "short_code", nullable = false, unique = true, length = 10)
private String shortCode;

@Column(name = "original_url", nullable = false, length = 2048)
private String originalUrl;

@Builder.Default
@Column(name = "click_count", nullable = false)
private long clickCount = 0;

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@Column(name = "expires_at")
private Instant expiresAt;

@PrePersist
void prePersist() {
createdAt = Instant.now();
}
}

allocationSize = 1 tells Hibernate to fetch the next sequence value from the database for each INSERT rather than pre-allocating IDs in batches. This keeps the ID sequence strictly aligned with the database sequence, which matters because we use the ID to derive the short code. In high-write scenarios (tens of thousands of URLs per second), increase allocationSize to reduce sequence fetches at the cost of ID gaps

Request/Response DTOs

Java 21 records eliminate boilerplate for immutable data carriers:

// Input validation at the boundary
public record ShortenRequest(
@NotBlank(message = "URL must not be blank")
@Size(max = 2048, message = "URL must not exceed 2048 characters")
@Pattern(regexp = "^https?://.*", message = "URL must start with http:// or https://")
String url
) {}

public record ShortenResponse(
String shortCode,
String shortUrl,
String originalUrl,
Instant createdAt
) {}

public record UrlStatsResponse(
String shortCode,
String originalUrl,
long clickCount,
Instant createdAt
) {}

public record ErrorResponse(
int status,
String error,
String message,
Instant timestamp
) {}

Base62 Encoder

@Component
public class Base62Encoder {

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

public String encode(long id) {
if (id <= 0) throw new IllegalArgumentException("ID must be positive, got: " + id);

StringBuilder result = new StringBuilder();
long remaining = id;

while (remaining > 0) {
result.append(ALPHABET.charAt((int) (remaining % BASE)));
remaining /= BASE;
}

return result.reverse().toString();
}

public long decode(String encoded) {
if (encoded == null || encoded.isBlank()) {
throw new IllegalArgumentException("Encoded value must not be blank");
}

long result = 0;
for (char c : encoded.toCharArray()) {
int index = ALPHABET.indexOf(c);
if (index == -1) throw new IllegalArgumentException("Invalid character in encoded value: " + c);
result = result * BASE + index;
}

return result;
}
}

The encoding is a standard base conversion. encode(1)"1", encode(62)"10", encode(3844)"100". The resulting codes are monotonically increasing and URL-safe.

Repository

@Repository
public interface UrlMappingRepository extends JpaRepository<UrlMapping, Long> {

Optional<UrlMapping> findByShortCode(String shortCode);

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

The @Modifying JPQL query issues a single UPDATE without loading the entity into memory — critical for the high-frequency click tracking path.

Click Tracking Service

@Service
@RequiredArgsConstructor
@Slf4j
public class ClickTrackingService {

private final UrlMappingRepository repository;

@Async("clickTrackingExecutor")
@Transactional
public void recordClick(String shortCode) {
try {
repository.incrementClickCount(shortCode);
} catch (Exception ex) {
// Click tracking is best-effort; a lost count is preferable to a failed redirect
log.warn("Failed to record click for shortCode={}: {}", shortCode, ex.getMessage());
}
}
}

The @Async annotation dispatches this to the dedicated thread pool, releasing the request thread immediately. Click tracking failures are logged but never propagated — a missed count is far less harmful than a failed redirect.

URL Shortener Service

This is where the cache-aside pattern lives:

@Service
@RequiredArgsConstructor
@Slf4j
public class UrlShortenerService {

private static final String CACHE_PREFIX = "url:";

private final UrlMappingRepository repository;
private final RedisTemplate<String, String> redisTemplate;
private final Base62Encoder encoder;
private final ClickTrackingService clickTrackingService;
private final UrlShortenerProperties properties;

@Transactional
public ShortenResponse shorten(ShortenRequest request) {
// Use a UUID placeholder so the NOT NULL constraint is satisfied on INSERT.
// JPA dirty-checking replaces it with the Base62-encoded ID before commit.
UrlMapping mapping = repository.saveAndFlush(
UrlMapping.builder()
.originalUrl(request.url())
.shortCode(UUID.randomUUID().toString().replace("-", "").substring(0, 10))
.build()
);

String shortCode = encoder.encode(mapping.getId());
mapping.setShortCode(shortCode);

cacheUrl(shortCode, request.url());
log.info("Shortened url={} shortCode={}", request.url(), shortCode);

return new ShortenResponse(
shortCode,
properties.baseUrl() + "/" + shortCode,
request.url(),
mapping.getCreatedAt()
);
}

@Transactional(readOnly = true)
public String resolveUrl(String shortCode) {
String cached = redisTemplate.opsForValue().get(CACHE_PREFIX + shortCode);

if (cached != null) {
log.debug("Cache hit shortCode={}", shortCode);
clickTrackingService.recordClick(shortCode);
return cached;
}

log.debug("Cache miss shortCode={}", shortCode);

UrlMapping mapping = repository.findByShortCode(shortCode)
.orElseThrow(() -> new UrlNotFoundException(shortCode));

cacheUrl(shortCode, mapping.getOriginalUrl());
clickTrackingService.recordClick(shortCode);
return mapping.getOriginalUrl();
}

@Transactional(readOnly = true)
public UrlStatsResponse getStats(String shortCode) {
UrlMapping mapping = repository.findByShortCode(shortCode)
.orElseThrow(() -> new UrlNotFoundException(shortCode));

return new UrlStatsResponse(
mapping.getShortCode(),
mapping.getOriginalUrl(),
mapping.getClickCount(),
mapping.getCreatedAt()
);
}

private void cacheUrl(String shortCode, String originalUrl) {
redisTemplate.opsForValue().set(
CACHE_PREFIX + shortCode,
originalUrl,
Duration.ofHours(properties.cacheTtlHours())
);
}
}

Why saveAndFlush instead of save? The standard save defers the INSERT to flush time (usually transaction commit). saveAndFlush forces it immediately so we have the database-generated id in hand before computing the Base62 short code. Without it, mapping.getId() returns null.

The placeholder technique: The short_code column has a NOT NULL UNIQUE constraint. We can’t insert without a value. The solution is a UUID-based placeholder that satisfies the constraint on INSERT. When mapping.setShortCode(shortCode) runs on the managed entity, JPA’s dirty checking detects the change and issues an UPDATE url_mappings SET short_code = ? WHERE id = ? before the transaction commits. The UUID placeholder is never visible outside this transaction.

Controllers

@RestController
@RequestMapping("/api/v1/urls")
@RequiredArgsConstructor
public class UrlController {

private final UrlShortenerService service;

@PostMapping
public ResponseEntity<ShortenResponse> shorten(@Valid @RequestBody ShortenRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.shorten(request));
}

@GetMapping("/{shortCode}/stats")
public ResponseEntity<UrlStatsResponse> getStats(@PathVariable String shortCode) {
return ResponseEntity.ok(service.getStats(shortCode));
}
}
@RestController
@RequiredArgsConstructor
public class RedirectController {

private final UrlShortenerService service;

@GetMapping("/{shortCode:[0-9A-Za-z]+}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
String originalUrl = service.resolveUrl(shortCode);

HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(originalUrl));
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
}

The regex constraint [0-9A-Za-z]+ on the path variable prevents non-Base62 characters from ever reaching the service layer. This also avoids path conflicts with other endpoints like /api.

Testing

Running the Application

# Start infrastructure
docker compose up -d

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

Shorten a URL

Follow the Redirect

curl -v http://localhost:8080/1

Check Statistics

Validation Error

Performance Considerations

Cache Hit Rate is Everything

A URL shortener’s performance lives and dies by cache hit rate. At 24-hour TTL with LRU eviction, frequently-clicked URLs stay warm indefinitely. Rarely-clicked URLs evict naturally. Aim for >95% cache hit rate in production — anything lower suggests either insufficient Redis memory or an unusual URL distribution.

Monitor hit rate in Redis:

redis-cli info stats | grep keyspace

The 80/20 Rule in Practice

Empirically, 80% of redirects go to 20% of URLs. For a service with 10 million stored URLs, 2 million URLs handle the bulk of traffic. At ~500 bytes per cached entry (key + value), that’s roughly 1 GB to cache the hot set — well within reach of a single Redis instance.

Click Tracking at Scale

The async click tracking approach works until you hit very high traffic on a single URL (viral content, large events). At that scale, individual UPDATE click_count = click_count + 1 queries become contention points. Consider batching click counts in Redis with INCR and flushing to PostgreSQL on an interval, or accepting approximate counts via probabilistic data structures.

Read Replicas

When PostgreSQL becomes the bottleneck on cache misses, add read replicas and route findByShortCode queries to them. Spring Boot supports this with AbstractRoutingDataSource or via Spring Data JPA routing.

Connection Pool Tuning

Hikari’s default maximum-pool-size of 10 is low for redirect traffic. Set it relative to the expected concurrent database connections. A formula: pool_size = ((core_count * 2) + effective_spindle_count). For a 4-core instance with SSD, start at 9.

Common Pitfalls

Missing the index on short_code. This is the most common mistake. Without it, every redirect triggers a sequential table scan. At millions of rows, that’s fatal. The Flyway migration creates it — don’t remove it.

Synchronous click tracking. If incrementClickCount runs in the request thread, a slow database update adds 10-50ms to every redirect latency. The async approach sacrifices some click count accuracy (missed counts on process kill) in exchange for consistent sub-5ms redirect latency.

Wrong Redis eviction policy. noeviction (Redis default) causes Redis to return errors when memory is full rather than evicting stale keys. For a cache, always set maxmemory-policy. allkeys-lru is the right choice for URL shorteners.

Using UUID as short code. UUIDs are 36 characters — far too long for a “short” URL. They also require a separate uniqueness check. Base62 from sequential IDs avoids both problems.

ddl-auto: create-drop in production. Drop it from your production configuration. Use validate and let Flyway manage schema changes.

Cache stampede on cold start. After a restart, if thousands of requests for the same popular URL arrive simultaneously before the cache warms up, all of them hit PostgreSQL. For high-traffic deployments, pre-warm the cache at startup by loading the top-N most-clicked URLs.

Conclusion

🏁 Well done !!. In this post, we designed and built a URL shortener system using Spring Boot, PostgreSQL and Redis.

The Base62 + PostgreSQL + Redis approach in this article is appropriate for services handling tens of millions of URLs and thousands of redirects per second. Beyond that, the PostgreSQL sequence becomes a write bottleneck, and you’d move to distributed ID generation (Twitter Snowflake, ULID, or a Redis counter) and horizontal database sharding.

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for reading!! See you in the next post.

References

👉 Link to Medium blog

Related Posts