Rijmwoordenboek: Serving Pages Under 15ms with Better Caching
Categories
Tags
About the Author
Rob Schoenaker
Managing Partner at UpstreamAds and Partner at Ludulicious B.V. with over 20 years of experience in software development, specializing in .NET Core, ServiceStack, C# and database design.
The Problem: Page Load Times Killing User Experience
In 2021, Van Dale Rijmwoordenboek faced a critical user experience issue. Even after optimizing our phonetic search queries, page load times were still over 100ms. For a web application, this was unacceptable.
The Challenge:
- Page load times averaging 100-150ms
- Users expecting instant page responses
- Database queries running on every page load
- No caching strategy in place
The Numbers:
-- Every page load was hitting the database
SELECT word, phonetic_code, frequency, definition
FROM words
WHERE phonetic_code LIKE 'KAT%'
ORDER BY frequency DESC
LIMIT 20;
-- Execution time: 85ms per page load
The Root Cause: No Caching Strategy
The problem was clear from our monitoring:
What was happening:
- Every page load executed fresh database queries
- No application-level caching implemented
- Database was hit for identical queries repeatedly
- Response times varied based on database load
The Solution: Multi-Layer Caching Strategy
Step 1: Application-Level Caching with Redis
The first breakthrough came with Redis caching:
// Application-level caching implementation
public async Task<List<Word>> GetWordsByPhoneticCode(string phoneticCode)
{
var cacheKey = $"words:phonetic:{phoneticCode}";
// Try to get from cache first
var cachedWords = await _redis.GetAsync<List<Word>>(cacheKey);
if (cachedWords != null)
{
return cachedWords; // Return cached result immediately
}
// If not in cache, query database
var words = await _database.QueryAsync<Word>(
"SELECT word, phonetic_code, frequency FROM words WHERE phonetic_code LIKE @code ORDER BY frequency DESC LIMIT 20",
new { code = phoneticCode + "%" }
);
// Cache the result for 5 minutes
await _redis.SetAsync(cacheKey, words, TimeSpan.FromMinutes(5));
return words;
}
Why This Works:
Redis.GetAsync(): Checks cache first, returns immediately if foundTimeSpan.FromMinutes(5): Caches results for 5 minutes, balancing freshness with performance- Eliminates database hits for repeated queries
- Dramatically reduces response times for cached data
Immediate Result: Cached queries dropped from 85ms to 2ms (42x improvement)
Step 2: Database Query Result Caching
For frequently accessed phonetic patterns, we implemented database-level caching:
-- Create materialized view for common phonetic patterns
CREATE MATERIALIZED VIEW words_common_patterns AS
SELECT phonetic_code,
array_agg(word ORDER BY frequency DESC) as words,
array_agg(frequency ORDER BY frequency DESC) as frequencies
FROM words
WHERE phonetic_code IN (
SELECT phonetic_code
FROM words
GROUP BY phonetic_code
HAVING count(*) > 10
)
GROUP BY phonetic_code;
-- Refresh materialized view every hour
CREATE OR REPLACE FUNCTION refresh_words_patterns()
RETURNS void AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY words_common_patterns;
END;
$$ LANGUAGE plpgsql;
-- Schedule refresh every hour
SELECT cron.schedule('refresh-words-patterns', '0 * * * *', 'SELECT refresh_words_patterns();');
Why This Works:
MATERIALIZED VIEW: Pre-computes common phonetic patternsREFRESH MATERIALIZED VIEW CONCURRENTLY: Updates without blocking readscron.schedule(): Automatically refreshes data every hour- Eliminates expensive GROUP BY operations for common queries
Result: Common pattern queries improved to 15ms (5.7x improvement)
Step 3: HTTP Response Caching
For static phonetic data, we implemented HTTP caching:
// HTTP response caching implementation
[HttpGet("phonetic/{phoneticCode}")]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> GetWordsByPhonetic(string phoneticCode)
{
var words = await GetWordsByPhoneticCode(phoneticCode);
return Ok(words);
}
Why This Works:
ResponseCache(Duration = 300): Caches HTTP responses for 5 minutesResponseCacheLocation.Any: Allows caching at any level (browser, CDN, proxy)- Reduces server load for repeated requests
- Improves response times for users with cached responses
Result: HTTP response times improved to 8ms (12.5x improvement)
The Game Changer: Smart Cache Invalidation
The Problem: Stale Data Issues
With caching in place, we faced stale data problems:
// Problem: Cache not invalidated when data changes
public async Task UpdateWordFrequency(int wordId, int newFrequency)
{
await _database.ExecuteAsync(
"UPDATE words SET frequency = @frequency WHERE id = @id",
new { frequency = newFrequency, id = wordId }
);
// Cache not invalidated - users see stale data!
}
The Solution: Intelligent Cache Invalidation
We implemented smart cache invalidation:
// Smart cache invalidation implementation
public async Task UpdateWordFrequency(int wordId, int newFrequency)
{
// Get the word to find its phonetic code
var word = await _database.QueryFirstAsync<Word>(
"SELECT phonetic_code FROM words WHERE id = @id",
new { id = wordId }
);
// Update the database
await _database.ExecuteAsync(
"UPDATE words SET frequency = @frequency WHERE id = @id",
new { frequency = newFrequency, id = wordId }
);
// Invalidate related caches
var cacheKey = $"words:phonetic:{word.PhoneticCode}";
await _redis.RemoveAsync(cacheKey);
// Also invalidate pattern cache if this affects common patterns
if (newFrequency > 1000)
{
await _redis.RemoveAsync("words:common_patterns");
}
}
Why This Works:
- Invalidates specific phonetic code caches when data changes
- Invalidates pattern caches when frequency thresholds change
- Ensures users always see fresh data
- Maintains cache performance benefits
Result: Cache hit rate improved to 95% while maintaining data freshness
The Final Optimization: Preemptive Caching
The Problem: Cache Misses During Peak Usage
During peak usage, cache misses were still causing slow responses:
// Problem: Cache misses during peak usage
public async Task<List<Word>> GetWordsByPhoneticCode(string phoneticCode)
{
var cacheKey = $"words:phonetic:{phoneticCode}";
var cachedWords = await _redis.GetAsync<List<Word>>(cacheKey);
if (cachedWords == null)
{
// Cache miss - slow database query during peak usage
var words = await _database.QueryAsync<Word>(...);
await _redis.SetAsync(cacheKey, words, TimeSpan.FromMinutes(5));
return words;
}
return cachedWords;
}
The Solution: Background Cache Warming
We implemented background cache warming:
// Background cache warming implementation
public class CacheWarmingService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Get most popular phonetic codes
var popularCodes = await _database.QueryAsync<string>(
"SELECT phonetic_code FROM words GROUP BY phonetic_code ORDER BY count(*) DESC LIMIT 100"
);
// Pre-warm cache for popular codes
foreach (var code in popularCodes)
{
var cacheKey = $"words:phonetic:{code}";
var exists = await _redis.ExistsAsync(cacheKey);
if (!exists)
{
var words = await _database.QueryAsync<Word>(
"SELECT word, phonetic_code, frequency FROM words WHERE phonetic_code LIKE @code ORDER BY frequency DESC LIMIT 20",
new { code = code + "%" }
);
await _redis.SetAsync(cacheKey, words, TimeSpan.FromMinutes(5));
}
}
// Wait 10 minutes before next warming cycle
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
}
}
}
Why This Works:
- Pre-warms cache for most popular phonetic codes
- Runs in background without affecting user requests
- Reduces cache misses during peak usage
- Ensures popular queries are always cached
Result: Cache hit rate improved to 98%, response times consistently under 15ms
Performance Results Summary
| Optimization Step | Response Time | Improvement |
|---|---|---|
| Original (No Caching) | 100-150ms | Baseline |
| Redis Application Caching | 2ms | 50-75x faster |
| Database Materialized Views | 15ms | 6.7-10x faster |
| HTTP Response Caching | 8ms | 12.5-18.8x faster |
| Smart Cache Invalidation | 2ms | 50-75x faster |
| Background Cache Warming | <15ms | 6.7-10x faster |
Key Lessons Learned
1. Multi-Layer Caching Is Essential
- Application-level caching eliminates database hits
- Database-level caching optimizes expensive queries
- HTTP-level caching reduces server load
2. Cache Invalidation Strategy Matters
- Smart invalidation ensures data freshness
- Invalidate related caches when data changes
- Balance cache performance with data accuracy
3. Background Processing Prevents Cache Misses
- Pre-warm cache for popular queries
- Run warming processes during off-peak hours
- Monitor cache hit rates and adjust strategies
4. Cache Duration Optimization
- Balance freshness with performance
- Longer cache times for stable data
- Shorter cache times for frequently changing data
5. Monitor Cache Performance
- Track cache hit rates
- Monitor response times
- Adjust caching strategies based on usage patterns
Implementation Checklist
If you're facing similar page load performance issues:
- Implement application-level caching: Use Redis or similar
- Add database-level caching: Use materialized views for expensive queries
- Implement HTTP response caching: Cache static responses
- Design cache invalidation strategy: Ensure data freshness
- Add background cache warming: Pre-warm popular queries
- Monitor cache performance: Track hit rates and response times
- Optimize cache durations: Balance freshness with performance
Summary
Optimizing page load times requires a comprehensive caching strategy. By combining Redis application caching, database materialized views, HTTP response caching, smart cache invalidation, and background cache warming, we achieved consistent sub-15ms response times for Rijmwoordenboek.
The key was understanding that caching isn't just about storing data—it's about creating a multi-layer strategy that eliminates bottlenecks at every level while maintaining data freshness.
If this article helped you understand caching optimization, we can help you implement these techniques in your own applications. At Ludulicious, we specialize in:
- Caching Strategies: Multi-layer caching solutions for optimal performance
- Database Performance Optimization: From slow queries to indexing strategies
- Custom Development: Tailored solutions for your specific use case
Ready to optimize your page load times?
Contact us for a free consultation, or check out our other optimization guides:
- PostgreSQL Performance Tuning: Strategic Lessons from Production
- Duikersgids: How I Made Spatial Search 55x Faster
- Rijmwoordenboek: Solving the 3-Second Phonetic Search Problem
- UpstreamAds: From 1.2s to 35ms Full-Text Search
- PostgreSQL Configuration: The Settings That Matter
This optimization case study is based on real production experience with Van Dale Rijmwoordenboek. All performance numbers are from actual production systems.
Rijmwoordenboek: Solving the 3-Second Phonetic Search Problem
Learn how we optimized PostgreSQL phonetic search for Van Dale Rijmwoordenboek, reducing search times from 3.2 seconds to 85ms using multi-layered indexing strategies and B-tree deduplication.
UpstreamAds: From 1.2s to 35ms Full-Text Search
Learn how we optimized PostgreSQL full-text search for UpstreamAds, reducing search times from 1.2 seconds to 35ms using pre-computed tsvector indexes, multi-language strategies, and partial indexing.