A Familiar N+1 Problem: The 26× Speedup from Caching
Overview
While operating a sports social platform, I noticed how slowly the feed loaded. Introducing Caffeine caching reduced average response time from roughly 800 ms to 38 ms.
Key metrics
- Response time: 800 ms → 38 ms, a 95% improvement.
- DB queries: 81 → 1, a 98% reduction.
- Throughput: 3.8 req/s → 78.2 req/s, roughly 20×.
1. The problem
1.1. Feed requirements
There are two kinds of feed items:
- Completed matches, including results, scores, and reviews.
- Upcoming matches in matching, scheduled, or in-progress states.
They needed to be sorted as follows:
- Matches involving friends first.
- Other matches next.
- Newest first within each priority.
Each feed item also contains user-specific flags such as isFriend and isMyGame.
1.2. Initial implementation
A native query using UNION ALL implemented friend-first ordering.
fun getFeed(myUserId: Long): FeedResponse {
val query = entityManager.createNativeQuery("""
SELECT gr.id, 1 as priority FROM game_records gr
JOIN custom_game g ON gr.game_id = g.id
JOIN game_participation p ON p.game_id = g.id
JOIN friend f ON f.user_id = :myUserId AND f.friend_id = p.user_id
WHERE g.game_status = 'COMPLETED' AND f.status = 'ACCEPTED'
UNION ALL
-- Upcoming matches (friends) ...
UNION ALL
-- Completed matches (non-friends) ...
UNION ALL
-- Upcoming matches (non-friends) ...
ORDER BY priority, created_at DESC
""")
return results.map { toFeedItemResponse(it) }
}
1.3. Performance bottlenecks
Measurement environment
- 500 matches.
- 50 users.
- An average of five friends per user.
Response time 
Average: 820ms
Median: 780ms
P95: 1200ms
P99: 1800ms
Bottlenecks
Query execution: 650ms (79%)
N+1 queries: 120ms (15%)
Object mapping: 30ms (4%)
Other: 20ms (2%)
Problem 1: repeated UNION ALL scans
UNION ALL combined four independent query branches. In this query plan, MySQL repeated full table scans instead of eliminating the redundant work.
EXPLAIN ANALYZE results:
- game_records (500 rows) × 2 scans
- custom_game (500 rows) × 2 scans
- friend (250 rows) × 2 scans
Problem 2: N+1 queries
Lazy loading and additional lookups occurred during DTO conversion.
results.map { toFeedItemResponse(it) }
For each item:
game.rule(Lazy Loading)game.participations(Lazy Loading)reviewRepository.findByGameId()commentService.getCommentCount()
Actual query count: 1 main query + 20 × 4 = 81 queries.
Problem 3: inefficient pagination
OFFSET pagination scans and discards rows up to the offset, making later pages slower.
2. The solution
2.1. Choosing caching
The feed's characteristics were a textbook fit for caching.
- A read-to-write ratio of 100:1.
- Completed matches don't change.
- Upcoming matches change infrequently.
2.2. Why Caffeine?
Initially, I wondered whether a ConcurrentHashMap<Long, FeedItemResponse> would be enough.
// Simple map approach
private val cache = ConcurrentHashMap<Long, FeedItemResponse>()
fun put(id: Long, item: FeedItemResponse) {
cache[id] = item
}
The issue is memory management. Without explicit removal, entries accumulate. Dozens of new matches each day would require custom eviction logic.
Caffeine handles that:
Caffeine.newBuilder()
.maximumSize(5000) // Evict automatically above 5000 entries
.recordStats() // Monitor hit rate and eviction count
.build()
- maximumSize: Window TinyLFU manages eviction based on access patterns.
- recordStats: hit-rate monitoring supports tuning.
A tested library was preferable to writing my own LRU implementation.
2.3 Redis vs Caffeine
I compared Redis with Caffeine.
| Property | Redis | Caffeine |
|---|---|---|
| Latency | 1-5ms (network) | <0.1ms (in-memory) |
| Operational complexity | Higher (separate server) | Lower (embedded) |
| Environment | Distributed | Single instance |
We had one instance and didn't need network I/O for a shared cache. I chose Caffeine.
2.4. Architecture
[Server startup]
↓
warmupCache()
├─ Completed matches from the last 6 weeks → Completed Cache (max 5000)
└─ Upcoming/in-progress matches → Upcoming Cache (max 2000)
[Read request]
↓
1. Load friend IDs (1 query)
2. Read cached feed (0 queries)
3. Sort/filter in memory
4. Paginate
↓
[Response]
The server loads feed data from the DB at startup, then serves that data from memory.
2.5. Design decisions
No TTL
I initially set expireAfterWrite(30, MINUTES), then reconsidered:
- Warmup only loads data from the last six weeks.
- Old data isn't loaded in the first place.
- TTL management added overhead we didn't need.
I controlled memory with maximumSize and left eviction to Caffeine's Window TinyLFU policy.
private val completedGameCache = Caffeine.newBuilder()
.maximumSize(5000)
.recordStats()
.build()
Separate user-specific data
At first, I cached user-specific fields such as isFriend and isMyGame. That caused a bug: all users shared the cache, so one user's flags could appear for another.
Before: the bug
val feedItem = toFeedItemResponse(
gameRecord,
isFriend = gameService.isFriend(myUserId),
isMyGame = gameService.isMyGame(myUserId)
)
cache.put(gameRecordId, feedItem)
After
// Cache only user-independent data
val feedItem = toFeedItemResponse(
gameRecord,
isFriend = false,
isMyGame = false
)
cache.put(gameRecordId, feedItem)
// Compute user-specific fields at read time
fun getFeed(myUserId: Long): FeedResponse {
val myFriendIds = friendRepository
.findAllByUserIdAndStatus(myUserId, ACCEPTED)
.map { it.friend.id }
.toSet()
val allFeeds = cache.getAllFeeds()
return allFeeds.map { feedItem ->
feedItem.copy(
isFriend = feedItem.players.any { it.id in myFriendIds },
isMyGame = feedItem.players.any { it.id == myUserId }
)
}
}
I changed the design to cache only user-independent data and compute user-specific fields at read time.
3. Implementation
3.1. Cache service
@Service
class FeedCacheService {
private val completedGameCache: Cache<Long, FeedItemResponse> =
Caffeine.newBuilder()
.maximumSize(5000)
.recordStats()
.build()
private val upcomingGameCache: Cache<Long, FeedItemResponse> =
Caffeine.newBuilder()
.maximumSize(2000)
.recordStats()
.build()
fun putCompletedGame(id: Long, item: FeedItemResponse) {
completedGameCache.put(id, item)
}
fun getAllFeeds(): List<FeedItemResponse> {
return completedGameCache.asMap().values.toList() +
upcomingGameCache.asMap().values.toList()
}
}
3.2. Cache warmup
@PostConstruct
@Transactional(readOnly = true)
fun warmupFeedCache() {
val sixWeeksAgo = LocalDateTime.now().minusWeeks(6)
// Load completed matches
val completedGames = gameRecordRepository.findAll()
.filter { it.recordTime.isAfter(sixWeeksAgo) }
completedGames.forEach { gameRecord ->
try {
val feedItem = toFeedItemResponse(gameRecord, false, false)
feedCacheService.putCompletedGame(gameRecord.id, feedItem)
} catch (e: Exception) {
log.error(e) { "Cache warmup failed: ${gameRecord.id}" }
}
}
// Load upcoming matches
val upcomingStatuses = listOf(MATCHING, SCHEDULED, IN_PROGRESS)
val upcomingGames = gameRepository.findAll()
.filter { it.gameStatus in upcomingStatuses }
upcomingGames.forEach { game ->
try {
val feedItem = toUpcomingGameFeedItem(game, false, false)
feedCacheService.putUpcomingGame(game.id, feedItem)
} catch (e: Exception) {
log.error(e) { "Cache warmup failed: ${game.id}" }
}
}
log.info { "Cache warmup complete: ${completedGames.size + upcomingGames.size}" }
}
Warming 500 matches takes about two seconds. Startup gets a little longer, but the cost is acceptable.
3.3. Feed reads
fun getFeed(myUserId: Long, page: Int = 0, size: Int = 20): FeedResponse {
// 1. Load friend IDs (1 query)
val myFriendIds = friendRepository
.findAllByUserIdAndStatus(myUserId, FriendStatus.ACCEPTED)
.map { it.friend.id!! }
.toSet()
// 2. Read feed from cache (0 query)
val allFeeds = feedCacheService.getAllFeeds()
// 3. Compute user-specific fields and sort
val processedFeeds = allFeeds
.map { feedItem ->
feedItem.copy(
isFriend = feedItem.players.any { it.id in myFriendIds },
isMyGame = feedItem.players.any { it.id == myUserId }
)
}
.sortedWith(
compareBy<FeedItemResponse> {
when {
it.isFriend && it.isCompleted -> 1
it.isFriend && !it.isCompleted -> 2
!it.isFriend && it.isCompleted -> 3
else -> 4
}
}.thenByDescending { it.date }
)
// 4. Pagination
val startIndex = page * size
val endIndex = minOf(startIndex + size, processedFeeds.size)
val pagedFeeds = if (startIndex < processedFeeds.size) {
processedFeeds.subList(startIndex, endIndex)
} else {
emptyList()
}
return FeedResponse(
items = pagedFeeds,
totalPages = (processedFeeds.size + size - 1) / size,
currentPage = page
)
}
4. Performance measurements
4.1. Response time

| Metric | Before | After | Improvement |
|---|---|---|---|
| Average | 820ms | 38ms | 95% |
| Median | 780ms | 32ms | 96% |
| P95 | 1200ms | 65ms | 95% |
| P99 | 1800ms | 95ms | 95% |
4.2. Query count
- Before: 81 queries on average.
- After: one query for friend IDs.
Database query volume fell by 98%.
4.3. Processing-time breakdown after caching
Friend lookup: 5ms (13%)
Cache read: 1ms (3%)
Sorting/filtering: 15ms (39%)
Pagination: 2ms (5%)
Other: 15ms (40%)
Most work is now in memory.
4.4. Memory usage
250 completed matches: 22MB
250 upcoming matches: 23MB
Total: 45MB
Maximum (5000 entries): Estimated 450 MB
There is room within the server's 2 GB memory budget.
4.5. Concurrency test
I load-tested with Apache Bench:
ab -n 10000 -c 100 http://localhost:8080/api/feed
| Metric | Before | After | Improvement |
|---|---|---|---|
| Requests/sec | 3.8 | 78.2 | 20× |
| Time/request | 820ms | 38ms | 95% |
5. Limitations
5.1. Server restarts
A restart clears the cache. @PostConstruct warms it automatically, but that takes about two seconds. Redis is a future option if traffic grows.
5.2. Multiple instances
We currently run one instance. Adding servers gives each its own independent cache, introducing synchronization concerns.
Possible approaches
- Move to Redis.
- Broadcast invalidation events through Kafka.
- Consider load-balancer sticky sessions.
5.3. Friendship changes
Adding or removing a friend must immediately affect the feed. Computing isFriend at read time makes that possible without invalidating the shared cache.
6. Conclusion
Caching isn't just a speed technique. It's deciding when an expensive computation needs to run once rather than repeatedly.
Key decisions
- Cache only user-independent data.
- Compute user-specific data at read time.
- Warm the cache at startup.
Results
- Response time: 820 ms → 38 ms, a 95% improvement.
- DB queries: 81 → 1, a 98% reduction.
- Throughput: 3.8 req/s → 78.2 req/s, roughly 20×.
Trade-offs
- Longer startup time.
- Up to hundreds of MB of additional memory.
- More code complexity.