Skip to main content
surviving the spike

Cache-Aside vs Read-Through vs Write-Through

13 min read Chapter 18 of 66

Cache-Aside vs Read-Through vs Write-Through

Three caching patterns. Each makes a different tradeoff between consistency, complexity, and cold-start behavior. Choosing the wrong pattern for an endpoint does not show up in unit tests. It shows up at 2 AM when the cache is cold and your database connection pool is on fire.

The ride-hailing platform has endpoints with fundamentally different access patterns. Trip history is read-heavy with low write frequency. Driver availability is read-heavy with high write frequency. Fare calculation is compute-heavy with predictable inputs. Each one needs a different caching strategy. Applying cache-aside to everything is easy and wrong.

Cache-Aside: You Manage Everything

Cache-aside is the simplest pattern. The application checks the cache. On a hit, it returns the cached value. On a miss, it queries the database, stores the result in the cache, and returns it. The cache has no knowledge of the database. The application owns the entire flow.

The Implementation for Trip History

Trip history is the textbook cache-aside use case. Riders check their trip history a few times per day. The data changes only when a trip completes (at most a few times per day for an active rider). Staleness of 5 minutes is invisible to the user.

// SCALED: Cache-aside for trip history
@Service
public class TripHistoryService {

    private final TripRepository tripRepository;
    private final ReactiveRedisTemplate<String, Page<TripSummary>> redisTemplate;
    private static final Duration TTL = Duration.ofMinutes(5);

    @Cacheable(value = "tripHistory",
               key = "#riderId + ':' + #page + ':' + #size")
    public Mono<Page<TripSummary>> getTripHistory(
            String riderId, int page, int size) {
        log.debug("Cache miss for rider {} page {}", riderId, page);
        return tripRepository
            .findByRiderIdOrderByCompletedAtDesc(
                riderId, PageRequest.of(page, size))
            .map(this::toSummary)
            .collectList()
            .zipWith(tripRepository.countByRiderId(riderId))
            .map(tuple -> new PageImpl<>(
                tuple.getT1(), PageRequest.of(page, size), tuple.getT2()));
    }
}

With Spring’s @Cacheable, the cache-aside logic is implicit. Spring checks Redis before executing the method. On a miss, it executes the method and stores the result. The pattern is invisible in the code, which is both the benefit and the risk. Developers forget the cache exists and write code that assumes every call hits the database.

Cache-Aside Failure Mode: Thundering Herd

When the cache is cold (application restart, Redis flush, TTL expiration on a popular entry), every concurrent request for the same key sees a cache miss simultaneously. All of them query the database. For a popular rider with 200 concurrent requests, that means 200 identical database queries.

// BOTTLENECK: 200 concurrent misses, 200 database queries
// Spring @Cacheable does NOT prevent thundering herd by default

The mitigation is cache entry locking. Spring Cache does not support this natively with Redis. You need the @Cacheable(sync = true) parameter, which uses a local lock to ensure only one thread populates the cache on a miss.

// SCALED: Synchronized cache population
@Cacheable(value = "tripHistory",
           key = "#riderId + ':' + #page + ':' + #size",
           sync = true)
public Mono<Page<TripSummary>> getTripHistory(
        String riderId, int page, int size) {
    return tripRepository
        .findByRiderIdOrderByCompletedAtDesc(
            riderId, PageRequest.of(page, size))
        .map(this::toSummary)
        .collectList()
        .zipWith(tripRepository.countByRiderId(riderId))
        .map(tuple -> new PageImpl<>(
            tuple.getT1(), PageRequest.of(page, size), tuple.getT2()));
}

With sync = true, only one thread executes the database query for a given key. Other threads wait for the result. The database sees one query instead of 200. The tradeoff: the waiting threads are blocked, consuming a thread each. In a WebFlux application, this is problematic. The reactive alternative is to use a Mono-based cache with Mono.defer() and cache(), but that bypasses Spring Cache entirely.

Read-Through: The Cache Fetches For You

In the read-through pattern, the cache itself is responsible for fetching data on a miss. The application always reads from the cache. If the entry is missing, the cache calls a configured loader function, stores the result, and returns it.

The Implementation with Custom CacheManager

Spring does not provide a read-through RedisCacheManager out of the box. You build one by extending RedisCacheManager and providing a cache loader.

// SCALED: Read-through RedisCacheManager
public class ReadThroughRedisCacheManager extends RedisCacheManager {

    private final Map<String, Function<Object, Object>> cacheLoaders;

    public ReadThroughRedisCacheManager(
            RedisCacheWriter cacheWriter,
            RedisCacheConfiguration defaultConfig,
            Map<String, RedisCacheConfiguration> configMap,
            Map<String, Function<Object, Object>> cacheLoaders) {
        super(cacheWriter, defaultConfig, configMap);
        this.cacheLoaders = cacheLoaders;
    }

    @Override
    protected RedisCache createRedisCache(String name,
            RedisCacheConfiguration cacheConfig) {
        Function<Object, Object> loader = cacheLoaders.get(name);
        if (loader != null) {
            return new ReadThroughRedisCache(name,
                createRedisCacheWriter(), cacheConfig, loader);
        }
        return super.createRedisCache(name, cacheConfig);
    }
}

The custom RedisCache implementation:

public class ReadThroughRedisCache extends RedisCache {

    private final Function<Object, Object> loader;

    protected ReadThroughRedisCache(String name,
            RedisCacheWriter cacheWriter,
            RedisCacheConfiguration cacheConfig,
            Function<Object, Object> loader) {
        super(name, cacheWriter, cacheConfig);
        this.loader = loader;
    }

    @Override
    public <T> T get(Object key, Class<T> type) {
        T value = super.get(key, type);
        if (value == null) {
            value = type.cast(loader.apply(key));
            if (value != null) {
                put(key, value);
            }
        }
        return value;
    }
}

Register the loader for each cache:

@Bean
public RedisCacheManager cacheManager(
        LettuceConnectionFactory connectionFactory,
        TripRepository tripRepository) {
    Map<String, Function<Object, Object>> loaders = Map.of(
        "tripHistory", key -> {
            String[] parts = key.toString().split(":");
            String riderId = parts[0];
            int page = Integer.parseInt(parts[1]);
            int size = Integer.parseInt(parts[2]);
            return tripRepository
                .findByRiderIdOrderByCompletedAtDesc(
                    riderId, PageRequest.of(page, size))
                .collectList()
                .block(); // Blocking call in cache loader
        }
    );

    return new ReadThroughRedisCacheManager(
        RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory),
        defaultConfig(),
        perCacheConfig(),
        loaders
    );
}

Why Read-Through Is Rarely Worth It for Spring Applications

The read-through pattern adds complexity for marginal benefit. The .block() call in the cache loader defeats WebFlux’s non-blocking model. The custom CacheManager is code you maintain, test, and debug. The benefit over cache-aside is that the loading logic is centralized in the cache configuration rather than scattered across service methods. For a team with 3 services and 5 cache regions, this centralization is not worth the abstraction cost.

Read-through shines in systems where the cache is a separate infrastructure component (like Apache Geode or Hazelcast) with its own data loading pipeline. For Redis with Spring, cache-aside is simpler and achieves the same result.

Write-Through: Every Write Updates the Cache

In the write-through pattern, every write operation updates both the database and the cache atomically. The application never writes to the database without also updating the cache. Reads always hit the cache and never see stale data.

The Implementation for Driver Availability

Driver availability is the canonical write-through use case. Drivers update their status frequently (online, offline, on trip). Riders query driver availability constantly. The read-to-write ratio is approximately 100:1. A stale availability status means dispatching a ride to a driver who went offline 10 seconds ago.

// SCALED: Write-through for driver availability
@Service
public class DriverAvailabilityService {

    private final DriverRepository driverRepository;
    private final ReactiveStringRedisTemplate redisTemplate;
    private static final Duration AVAILABILITY_TTL = Duration.ofSeconds(30);

    public Mono<Void> updateAvailability(String driverId,
            DriverStatus status, Location location) {
        DriverAvailability availability = new DriverAvailability(
            driverId, status, location, Instant.now());

        // Write to database and cache in parallel
        Mono<DriverAvailability> dbWrite =
            driverRepository.save(availability);
        Mono<Boolean> cacheWrite = redisTemplate.opsForValue()
            .set(
                "driver:avail:" + driverId,
                serialize(availability),
                AVAILABILITY_TTL
            );

        return Mono.zip(dbWrite, cacheWrite).then();
    }

    public Mono<DriverAvailability> getAvailability(String driverId) {
        return redisTemplate.opsForValue()
            .get("driver:avail:" + driverId)
            .map(this::deserialize)
            .switchIfEmpty(
                driverRepository.findById(driverId)
                    .doOnSuccess(avail -> {
                        if (avail != null) {
                            redisTemplate.opsForValue()
                                .set("driver:avail:" + driverId,
                                     serialize(avail),
                                     AVAILABILITY_TTL)
                                .subscribe();
                        }
                    })
            );
    }
}

Notice that this bypasses Spring’s @Cacheable entirely. Write-through for driver availability requires RedisTemplate directly because @CachePut does not support parallel database and cache writes. The Mono.zip() executes both writes concurrently. If either fails, the zip propagates the error.

Write-Through Consistency Risk

The parallel write creates a consistency window. If the database write succeeds but the cache write fails, the cache holds stale data until the TTL expires (30 seconds). If the cache write succeeds but the database write fails, the cache holds data that does not exist in the database. Both scenarios are recoverable because the TTL is short and the driver will send another status update within seconds.

For endpoints where this inconsistency is unacceptable (financial transactions), write-through with Redis is the wrong choice. Use database transactions without caching, or use a transactional cache like Redis with Lua scripts that verify both writes.

Write-Behind: Dismissed for Financial Data

Write-behind (also called write-back) buffers writes in the cache and flushes to the database asynchronously. The application writes to Redis, returns immediately, and a background process persists to PostgreSQL on a schedule.

This pattern is tempting for high-write endpoints like location tracking. Drivers send GPS coordinates every 2 seconds. Writing each update to PostgreSQL synchronously is 500 writes per second per driver. With 10,000 active drivers, that is 5 million writes per second. Write-behind batches those writes and flushes every 5 seconds, reducing database writes by 10x.

The problem: write-behind means data loss on Redis failure. If Redis crashes between flush intervals, every write since the last flush is gone. For GPS coordinates, losing 5 seconds of location data is annoying. For fare calculations, trip completion events, or payment transactions, losing data means financial discrepancies that require manual reconciliation.

// BOTTLENECK: Write-behind for financial data
// If Redis crashes, completed trips and fare charges are LOST
// Reconciliation between rider charges and driver payouts fails
// Regulatory audit trail has gaps

Write-behind is acceptable for:

  • GPS location history (reconstructable from subsequent updates)
  • Analytics events (eventually consistent is fine)
  • View counts, click tracking (approximate is acceptable)

Write-behind is unacceptable for:

  • Trip completion events (financial record)
  • Fare charges (money moved)
  • Payment transactions (must be durable)
  • Driver earnings (payout calculations depend on completeness)

For the ride-hailing platform, write-behind has no place in the critical path. Use it for telemetry and analytics. Use write-through or cache-aside for everything else.

Decision Matrix for Ride-Hailing Endpoints

EndpointPatternTTLReasoning
Trip historyCache-aside5 minRead-heavy, low write frequency, staleness acceptable
Fare estimateCache-aside60sCompute-heavy, predictable inputs, grid cell key
Driver availabilityWrite-through30sHigh read:write ratio, freshness critical
User profileCache-aside + @CachePut1 hrRarely changes, @CachePut on updates
Surge multiplierCache-aside15sChanges frequently, short TTL compensates
Real-time driver locationNo cacheN/AChanges every 2 seconds, caching adds latency without benefit
Active trip statusNo cacheN/AMust reflect current state, any staleness breaks UX
Payment processingNo cacheN/AFinancial data, consistency over performance
ETA calculationCache-aside30sDepends on traffic, acceptable staleness for display
Promo codesCache-aside10 minRarely changes, but must evict on deactivation

Two endpoints have “No cache” as the answer. Caching real-time driver locations with even a 2-second TTL means the rider sees the driver 2 seconds behind their actual position. For active trip status, showing “driver en route” when the driver already arrived breaks rider trust.

Locust Test: Cache-Aside vs Read-Through Cold-Start Behavior

The key difference between cache-aside and read-through shows up during cold starts. When the application starts with an empty cache, both patterns must populate the cache from the database. The question is how they handle the population under concurrent load.

Test Setup

# locust/cache_pattern_comparison.py
from locust import HttpUser, task, between, events
import random
import time

class ColdStartUser(HttpUser):
    wait_time = between(0.05, 0.2)
    rider_ids = [f"rider_{i}" for i in range(500)]

    def on_start(self):
        # Record start time for cold-start analysis
        self.start_time = time.time()

    @task
    def get_trip_history(self):
        rider_id = random.choice(self.rider_ids)
        with self.client.get(
            f"/api/v1/trips/history?riderId={rider_id}&page=0&size=20",
            name="/api/v1/trips/history",
            catch_response=True
        ) as response:
            elapsed = time.time() - self.start_time
            if elapsed < 30:
                response.success()
                # Tag cold-start requests for separate analysis

Procedure

  1. Flush Redis: redis-cli FLUSHDB
  2. Restart the application
  3. Start Locust with 300 users, spawn rate 50/second
  4. Measure for 3 minutes
  5. Compare the first 30 seconds (cold) vs the last 2 minutes (warm)

Results: Cache-Aside (Spring @Cacheable)

PhaseDurationThroughputp95 LatencyDB QueriesError Rate
Cold (0-30s)30s2,100 req/min1,200ms2,1004.8%
Warm (30s-3min)150s48,000 req/min18ms3200.0%

During the cold phase, every request is a cache miss. With 300 concurrent users and sync = true, the database handles one query per unique rider ID. With 500 rider IDs and a spawn rate of 50 users per second, the database sees a sustained burst of ~500 queries in the first 10 seconds. The p95 latency spikes because threads block waiting for the synchronized cache population.

Results: Read-Through (Custom CacheManager)

PhaseDurationThroughputp95 LatencyDB QueriesError Rate
Cold (0-30s)30s1,800 req/min1,800ms1,8007.2%
Warm (30s-3min)150s47,200 req/min19ms3100.0%

The read-through pattern performs worse during cold start. The .block() call in the cache loader ties up Netty event loop threads. With 300 concurrent users, the event loop saturates faster than with cache-aside, where the blocking happens on a separate thread pool. The higher error rate (7.2% vs 4.8%) comes from request timeouts when the event loop cannot accept new connections.

Warm-State Performance

MetricCache-AsideRead-Through
Throughput48,000 req/min47,200 req/min
p50 latency7ms8ms
p95 latency18ms19ms
Cache hit rate94.2%93.8%

Once the cache is warm, both patterns perform identically within measurement noise. The 1.7% throughput difference is not statistically significant across multiple test runs.

The Verdict

Cache-aside with @Cacheable(sync = true) is the right choice for Spring WebFlux applications. Read-through adds implementation complexity, introduces blocking in the cache loader, and delivers worse cold-start performance. The theoretical benefit of centralized loading logic does not justify the operational cost.

If cold-start performance is critical, add cache warming on startup instead of switching patterns:

// SCALED: Cache warming eliminates cold-start problem entirely
@EventListener(ApplicationReadyEvent.class)
public void warmCaches() {
    log.info("Warming trip history cache for top riders");
    tripRepository.findTopActiveRiderIds(500)
        .flatMap(riderId -> getTripHistory(riderId, 0, 20))
        .doOnComplete(() -> log.info("Cache warming complete"))
        .subscribe();
}

With cache warming, the cold-start phase disappears. The application serves cached responses from the first request. The 30-second cold-start window with 4.8% error rate becomes zero errors from second one.

Summary

Cache-aside is the default pattern. Use it unless you have a specific reason not to. It works with Spring’s @Cacheable, handles WebFlux correctly, and the cold-start problem is solved by cache warming rather than by switching patterns.

Write-through is reserved for high read:write ratio endpoints where freshness is critical. Driver availability is the canonical example. Implement it with RedisTemplate directly, not with @CachePut, because you need parallel writes.

Write-behind is off limits for financial data. The risk of data loss on Redis failure makes it unsuitable for any endpoint where durability matters. Restrict it to telemetry and analytics.

Read-through is a pattern in search of a problem in the Spring ecosystem. The implementation complexity, the blocking cache loader, and the worse cold-start behavior make it the wrong choice for Redis-backed caching in a WebFlux application. Save it for dedicated caching systems like Hazelcast or Apache Geode where the cache has its own data loading infrastructure.