DEV Community

Cover image for Scaling Java with Write-Behind Caching
William Nogueira
William Nogueira

Posted on

Scaling Java with Write-Behind Caching

If you ask a Junior Developer to build a URL Shortener (like Bit.ly) that tracks click analytics, the logic usually looks like this:

  1. User clicks the short link (/abc).
  2. App fetches the original URL from the database.
  3. App increments the click counter in the database (UPDATE urls SET clicks = clicks + 1 ...).
  4. App redirects the user.

This works great for 100 users. It works okay for 1,000 users.
But at 10,000 requests per second (RPS)? Your database locks up, latency spikes, and your application crashes.

Why? Because Database I/O is expensive. Blocking a thread to write a "+1" to disk for every single click is a massive waste of resources.

In this article, I’ll show you how I solved this using the Write-Behind Caching pattern, Redis, and Java 25 Virtual Threads.


The Bottleneck: Synchronous Writes

In the naive approach, the "Read" (finding the URL) and the "Write" (tracking the click) are coupled.

If your database takes 50ms to perform the update, your maximum throughput per thread is capped. If traffic spikes, your connection pool exhausts, and requests start timing out.

The Solution: Write-Behind Caching

To achieve high scale, we need to decouple the analytics from the redirect. The user wants to go to Google.com; they don't care if we counted their click right now or 5 minutes from now.

Here is the architecture I implemented in my project:

  1. Fast Path: Increment the counter in Redis (Memory is fast!).
  2. Dirty Tracking: Add the Short Code to a "Dirty Set" in Redis so we know which records changed.
  3. Slow Path (Async): A background scheduler wakes up, reads the Dirty Set, and flushes the counts to the Database (DynamoDB/Postgres) in batches.

Let's look at the code.


Step 1: The "Fire and Forget" Increment

I used Spring Boot 4 and RedisTemplate. When a request comes in, we hit Redis. This operation takes sub-milliseconds.

@Async("clickExecutor")
public void incrementClickCount(String code) {
    // 1. Atomic Increment in Redis (Super fast)
    redisTemplate.opsForValue().increment("clicks:" + code, 1);

    // 2. Mark this code as "Dirty" so the scheduler knows it changed
    redisTemplate.opsForSet().add("dirty_urls", code);
}
Enter fullscreen mode Exit fullscreen mode

Notice the @Async. The controller returns the redirect immediately. This logic ensures the user experiences zero latency penalty for our analytics tracking:

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "clickExecutor")
    public Executor clickExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The Virtual Thread Scheduler

This is where Java 25 shines.

Historically, background tasks that talk to databases were heavy because they blocked OS threads. With Project Loom (Virtual Threads), we can spawn threads that are lightweight. If the database is slow, the Virtual Thread "parks" itself, freeing up the carrier thread to do other work.

Here is the scheduler that runs every 5 minutes:

@Scheduled(cron = "0 */5 * * * *") // Runs every 5 mins
public void persistClicks() {
    log.info("Starting sync job...");

    while (true) {
        // 1. Pop a batch of codes (e.g., 1000 at a time) from the Set
        var codes = redisTemplate.opsForSet().pop("dirty_urls", 1000);

        if (Objects.isNull(codes) || codes.isEmpty()) {
            break;
        }

        // 2. Process in parallel using Virtual Threads
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            codes.forEach(obj -> executor.submit(() -> syncToDb(obj.toString())));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The Atomic Sync

The syncToDb method is responsible for moving the data from Cache to Storage.

private void syncToDb(String code) {
    // 1. Get the count from Redis and RESET it to 0 atomically
    var clickCountObj = redisTemplate.opsForValue().getAndSet("clicks:" + code, "0");

    if (clickCountObj == null) return;
    long clicks = Long.parseLong(clickCountObj.toString());

    if (clicks > 0) {
        // 2. Update the Database (in this case, DynamoDB)
        repository.updateClickCount(code, clicks);
    }
}

public void updateClickCount(String code, long totalClicks) {
    dynamoDbClient.updateItem(req -> req
            .tableName("urls")
            .key(Map.of("code", AttributeValue.builder().s(code).build()))
            .updateExpression("ADD clicks :inc")
            .expressionAttributeValues(Map.of(
                    ":inc",
                    AttributeValue.builder().n(String.valueOf(totalClicks)).build()
            ))
    );
}

Enter fullscreen mode Exit fullscreen mode

The Trade-off: What about Data Loss?

In distributed systems, everything is a trade-off. By using Write-Behind, we gain massive performance, but we introduce a risk: Durability.

If the Redis instance crashes completely before the 5-minute sync triggers, we lose the clicks that happened in that window.

Why is this acceptable?

  1. Business Value: Losing 5 minutes of click analytics is usually acceptable. Losing the actual URL mappings (Create operation) is not. (Creation is synchronous; Analytics is asynchronous).
  2. Mitigation: We can enable Redis AOF (Append Only File) persistence to minimize data loss on crash.

The Results

By implementing this pattern:

  • Latency: The GET /{code} endpoint response time dropped from ~60ms (DB write) to ~5ms (Redis write).
  • Throughput: The database is no longer hammered by every single click. It only receives bulk updates every few minutes.
  • Cost: We reduced Database Write Units (WCUs), which is the most expensive part of cloud databases like DynamoDB.

Conclusion

You don't always need microservices to scale. Sometimes, you just need to stop treating your database like a notepad. Patterns like Write-Behind Caching, combined with modern tools like Virtual Threads, allow monoliths to handle massive loads efficiently.

You can check out the full implementation (including the Docker Compose setup and Testcontainers integration) in the repository below:

GitHub Repo: Distributed URL Shortener

Top comments (0)