Caching··9 min read

Rijmwoordenboek: Serving Pages Under 15ms with Better Caching

Learn how we optimized Rijmwoordenboek page load times from 100ms+ to under 15ms using application-level caching, database query optimization, and response time strategies.

Categories

Database OptimizationCaching Strategies

Tags

PostgreSQLApplication CachingResponse TimePerformance OptimizationRedisQuery OptimizationRijmwoordenboek

About the Author

Author avatar

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.

Share:

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 found
  • TimeSpan.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 patterns
  • REFRESH MATERIALIZED VIEW CONCURRENTLY: Updates without blocking reads
  • cron.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 minutes
  • ResponseCacheLocation.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 StepResponse TimeImprovement
Original (No Caching)100-150msBaseline
Redis Application Caching2ms50-75x faster
Database Materialized Views15ms6.7-10x faster
HTTP Response Caching8ms12.5-18.8x faster
Smart Cache Invalidation2ms50-75x faster
Background Cache Warming<15ms6.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:


This optimization case study is based on real production experience with Van Dale Rijmwoordenboek. All performance numbers are from actual production systems.