Solidis logoSolidis
← Back to Tutorials
Intermediate
25 min
Some Redis experience needed

Implementing a High-Performance Cache Layer

Build a caching layer that speeds up database queries, API calls and expensive computations.

What You'll Learn
  • Cache-aside (lazy loading) pattern
  • Cache invalidation strategies
  • Cache warming and preloading
  • Monitoring cache hit rates
Prerequisites
  • •Basic understanding of caching concepts
  • •Familiarity with Redis data types
  • •Database knowledge (PostgreSQL, MySQL, etc.)
1
Create Cache Manager
A cache manager with several strategies
1import { SolidisFeaturedClient } from '@vcms-io/solidis/featured';
2
3export interface CacheOptions {
4  ttl?: number; // Time to live in seconds
5  prefix?: string;
6}
7
8export class CacheManager {
9  private client: SolidisFeaturedClient;
10  private prefix: string;
11  private defaultTTL: number;
12  private hits: number = 0;
13  private misses: number = 0;
14
15  constructor(options: {
16    host?: string;
17    port?: number;
18    prefix?: string;
19    defaultTTL?: number;
20  } = {}) {
21    this.client = new SolidisFeaturedClient({
22      host: options.host || '127.0.0.1',
23      port: options.port || 6379,
24    });
25    this.prefix = options.prefix || 'cache:';
26    this.defaultTTL = options.defaultTTL || 3600; // 1 hour default
27  }
28
29  async connect(): Promise<void> {
30    await this.client.connect();
31  }
32
33  disconnect(): void {
34    this.client.quit();
35  }
36
37  private getKey(key: string): string {
38    return `${this.prefix}${key}`;
39  }
40
41  /**
42   * Get value from cache
43   */
44  async get<T = any>(key: string): Promise<T | null> {
45    const cacheKey = this.getKey(key);
46    const value = await this.client.get(cacheKey);
47
48    if (value === null) {
49      this.misses++;
50      return null;
51    }
52
53    this.hits++;
54    return JSON.parse(value) as T;
55  }
56
57  /**
58   * Set value in cache
59   */
60  async set<T = any>(
61    key: string,
62    value: T,
63    options: CacheOptions = {}
64  ): Promise<void> {
65    const cacheKey = this.getKey(key);
66    const ttl = options.ttl || this.defaultTTL;
67
68    await this.client.set(
69      cacheKey,
70      JSON.stringify(value),
71      { expireInSeconds: ttl }
72    );
73  }
74
75  /**
76   * Cache-aside pattern: Get from cache or fetch from source
77   */
78  async getOrSet<T = any>(
79    key: string,
80    fetchFn: () => Promise<T>,
81    options: CacheOptions = {}
82  ): Promise<T> {
83    // Try cache first
84    const cached = await this.get<T>(key);
85    if (cached !== null) {
86      return cached;
87    }
88
89    // Cache miss - fetch from source, and cache only a value that exists
90    const value = await fetchFn();
91
92    if (value !== null) {
93      await this.set(key, value, options);
94    }
95
96    return value;
97  }
98
99  /**
100   * Delete from cache
101   */
102  async delete(key: string): Promise<boolean> {
103    const cacheKey = this.getKey(key);
104    const result = await this.client.del(cacheKey);
105    return result > 0;
106  }
107
108  /**
109   * Delete multiple keys by pattern
110   */
111  async deletePattern(pattern: string): Promise<number> {
112    const keys: string[] = [];
113
114    for await (const batch of this.client.scan({
115      match: `${this.prefix}${pattern}`,
116      count: 100,
117    })) {
118      keys.push(...batch);
119    }
120
121    if (keys.length === 0) {
122      return 0;
123    }
124
125    return await this.client.del(...keys);
126  }
127
128  /**
129   * Check if key exists in cache
130   */
131  async exists(key: string): Promise<boolean> {
132    const cacheKey = this.getKey(key);
133    const result = await this.client.exists(cacheKey);
134    return result === 1;
135  }
136
137  /**
138   * Extend TTL of existing key
139   */
140  async touch(key: string, ttl?: number): Promise<boolean> {
141    const cacheKey = this.getKey(key);
142    const expiry = ttl || this.defaultTTL;
143    const result = await this.client.expire(cacheKey, expiry);
144    return result > 0;
145  }
146
147  /**
148   * Get cache statistics
149   */
150  getStats() {
151    const total = this.hits + this.misses;
152    const hitRate = total > 0 ? (this.hits / total) * 100 : 0;
153
154    return {
155      hits: this.hits,
156      misses: this.misses,
157      total,
158      hitRate: hitRate.toFixed(2) + '%',
159    };
160  }
161
162  /**
163   * Reset statistics
164   */
165  resetStats() {
166    this.hits = 0;
167    this.misses = 0;
168  }
169}
2
Integrate with Database
Example with a user repository pattern
1import { CacheManager } from './cache-manager';
2
3export interface User {
4  id: string;
5  username: string;
6  email: string;
7  name: string;
8}
9
10export class UserRepository {
11  private cache: CacheManager;
12  private db: any; // Your database client
13
14  constructor(cache: CacheManager, db: any) {
15    this.cache = cache;
16    this.db = db;
17  }
18
19  /**
20   * Get user by ID with caching
21   */
22  async getUserById(userId: string): Promise<User | null> {
23    const cacheKey = `user:${userId}`;
24
25    return await this.cache.getOrSet(
26      cacheKey,
27      async () => {
28        // Fetch from database
29        const user = await this.db.query(
30          'SELECT * FROM users WHERE id = $1',
31          [userId]
32        );
33        return user.rows[0] || null;
34      },
35      { ttl: 300 } // 5 minutes
36    );
37  }
38
39  /**
40   * Get multiple users with batch caching
41   */
42  async getUsersByIds(userIds: string[]): Promise<User[]> {
43    const users: User[] = [];
44    const uncachedIds: string[] = [];
45
46    // Check cache for each user
47    for (const userId of userIds) {
48      const cacheKey = `user:${userId}`;
49      const cached = await this.cache.get<User>(cacheKey);
50
51      if (cached) {
52        users.push(cached);
53      } else {
54        uncachedIds.push(userId);
55      }
56    }
57
58    // Fetch uncached users from database
59    if (uncachedIds.length > 0) {
60      const result = await this.db.query(
61        'SELECT * FROM users WHERE id = ANY($1)',
62        [uncachedIds]
63      );
64
65      // Cache fetched users
66      for (const user of result.rows) {
67        users.push(user);
68        await this.cache.set(
69          `user:${user.id}`,
70          user,
71          { ttl: 300 }
72        );
73      }
74    }
75
76    return users;
77  }
78
79  /**
80   * Update user with cache invalidation
81   */
82  async updateUser(userId: string, data: Partial<User>): Promise<User> {
83    // Update database
84    const result = await this.db.query(
85      'UPDATE users SET username = COALESCE($1, username), email = COALESCE($2, email), name = COALESCE($3, name) WHERE id = $4 RETURNING *',
86      [data.username ?? null, data.email ?? null, data.name ?? null, userId]
87    );
88
89    const user = result.rows[0];
90
91    // Invalidate cache
92    await this.cache.delete(`user:${userId}`);
93
94    // Optionally: warm cache with new data
95    await this.cache.set(`user:${userId}`, user, { ttl: 300 });
96
97    return user;
98  }
99
100  /**
101   * Delete user with cache invalidation
102   */
103  async deleteUser(userId: string): Promise<boolean> {
104    // Delete from database
105    await this.db.query('DELETE FROM users WHERE id = $1', [userId]);
106
107    // Invalidate cache
108    await this.cache.delete(`user:${userId}`);
109
110    return true;
111  }
112
113  /**
114   * Search users (with result caching)
115   */
116  async searchUsers(query: string): Promise<User[]> {
117    const cacheKey = `search:users:${query}`;
118
119    return await this.cache.getOrSet(
120      cacheKey,
121      async () => {
122        const result = await this.db.query(
123          'SELECT * FROM users WHERE username ILIKE $1 OR email ILIKE $1 LIMIT 20',
124          [`%${query}%`]
125        );
126        return result.rows;
127      },
128      { ttl: 60 } // 1 minute (shorter for search results)
129    );
130  }
131}
3
Cache Warming Strategy
Preload frequently accessed data
1import { CacheManager } from './cache-manager';
2import type { User } from './user-repository';
3
4export class CacheWarmer {
5  private cache: CacheManager;
6  private db: any;
7
8  constructor(cache: CacheManager, db: any) {
9    this.cache = cache;
10    this.db = db;
11  }
12
13  /**
14   * Warm cache with popular users
15   */
16  async warmPopularUsers(limit: number = 100): Promise<void> {
17    const result = await this.db.query(
18      `SELECT * FROM users
19       ORDER BY last_login DESC
20       LIMIT $1`,
21      [limit]
22    );
23
24    const promises = result.rows.map((user: User) =>
25      this.cache.set(`user:${user.id}`, user, { ttl: 3600 })
26    );
27
28    await Promise.all(promises);
29    console.log(`Warmed cache with ${result.rows.length} users`);
30  }
31
32  /**
33   * Warm cache with featured content
34   */
35  async warmFeaturedContent(): Promise<void> {
36    const result = await this.db.query(
37      'SELECT * FROM posts WHERE featured = true'
38    );
39
40    const promises = result.rows.map((post: any) =>
41      this.cache.set(`post:${post.id}`, post, { ttl: 7200 })
42    );
43
44    await Promise.all(promises);
45    console.log(`Warmed cache with ${result.rows.length} featured posts`);
46  }
47
48  /**
49   * Schedule periodic cache warming
50   */
51  startPeriodicWarming(intervalMs: number = 3600000): NodeJS.Timer {
52    return setInterval(async () => {
53      try {
54        await this.warmPopularUsers();
55        await this.warmFeaturedContent();
56        console.log('Cache warming completed');
57      } catch (error) {
58        console.error('Cache warming failed:', error);
59      }
60    }, intervalMs);
61  }
62}
4
Complete Usage Example
Putting it all together in your application
1import express from 'express';
2import { CacheManager } from './cache-manager';
3import { UserRepository } from './user-repository';
4import { CacheWarmer } from './cache-warmer';
5import { Pool } from 'pg';
6
7const app = express();
8const db = new Pool({
9  connectionString: process.env.DATABASE_URL,
10});
11
12const cache = new CacheManager({
13  host: '127.0.0.1',
14  port: 6379,
15  prefix: 'myapp:',
16  defaultTTL: 3600,
17});
18
19const userRepo = new UserRepository(cache, db);
20const warmer = new CacheWarmer(cache, db);
21
22app.use(express.json());
23
24// Search users endpoint (with cached results)
25app.get('/api/users/search', async (req, res) => {
26  try {
27    const query = req.query.q as string;
28    const users = await userRepo.searchUsers(query);
29    res.json(users);
30  } catch (error) {
31    res.status(500).json({ error: 'Internal server error' });
32  }
33});
34
35// Get user endpoint (with caching)
36app.get('/api/users/:id', async (req, res) => {
37  try {
38    const user = await userRepo.getUserById(req.params.id);
39
40    if (!user) {
41      return res.status(404).json({ error: 'User not found' });
42    }
43
44    res.json(user);
45  } catch (error) {
46    res.status(500).json({ error: 'Internal server error' });
47  }
48});
49
50// Update user endpoint (with cache invalidation)
51app.put('/api/users/:id', async (req, res) => {
52  try {
53    const user = await userRepo.updateUser(req.params.id, req.body);
54    res.json(user);
55  } catch (error) {
56    res.status(500).json({ error: 'Internal server error' });
57  }
58});
59
60// Cache stats endpoint
61app.get('/api/cache/stats', (req, res) => {
62  const stats = cache.getStats();
63  res.json(stats);
64});
65
66// Start server
67async function start() {
68  await cache.connect();
69
70  // Warm cache on startup
71  await warmer.warmPopularUsers(100);
72  await warmer.warmFeaturedContent();
73
74  // Schedule periodic warming
75  warmer.startPeriodicWarming(3600000); // Every hour
76
77  app.listen(3000, () => {
78    console.log('Server running on http://localhost:3000');
79  });
80}
81
82start().catch(console.error);
Performance Optimization Tips
  • ✓
    Use appropriate TTL values
    Longer TTL for static data, shorter for frequently changing data
  • ✓
    Implement cache stampede protection
    Use locks to prevent multiple simultaneous cache misses
  • ✓
    Monitor cache hit rates
    Aim for 80%+ hit rate for optimal performance
  • ✓
    Use pipelining for batch operations
    Reduce network round trips when caching multiple items
  • ✓
    Implement tiered caching
    Combine in-memory cache (Node.js) with Redis for maximum performance
Cache Invalidation Strategies

Time-based (TTL)

Best for: Data that changes on a known schedule

Event-based

Best for: Data that changes based on user actions

Pattern-based

Best for: Invalidating related cache entries

Write-through

Best for: Data consistency requirements