Skip to main content

Query Caching

Query caching stores query results in memory so repeated reads can skip database round-trips.

Prerequisites

What You’ll Learn

  • How to enable and tune query-level caching
  • How cache invalidation and bypass options work
  • When to use memory caching vs direct DB reads

Step Outcome

By the end of this page, you should be able to cache read-heavy queries safely, inspect cache behavior, and invalidate cache on write boundaries.

1. Start with remember

Cache query results for a specified TTL:

Future<void> rememberExample(DataSource dataSource) async {
// Cache for 5 minutes
final activeUsers = await dataSource
.query<$User>()
.whereEquals('active', true)
.orderBy('name')
.remember(Duration(minutes: 5))
.get();

// Cache for 1 hour
final statistics = await dataSource
.query<$User>()
.remember(Duration(hours: 1))
.get();

// Cache for 1 day
final users = await dataSource
.query<$User>()
.remember(Duration(days: 1))
.get();
}

How it works:

  1. First query executes against the database
  2. Results are stored in cache with the specified TTL
  3. Equivalent subsequent queries return cached rows
  4. Cache entry expires after the duration

Use rememberForever() sparingly

Cache results indefinitely (until manually cleared):

Future<void> rememberForeverExample(DataSource dataSource) async {
// Good for reference data that rarely changes
final users = await dataSource
.query<$User>()
.orderBy('name')
.rememberForever()
.get();

final activeUsers = await dataSource
.query<$User>()
.whereEquals('active', true)
.rememberForever()
.get();
}
warning

Use rememberForever() carefully! Cached data won't update automatically. Clear the cache manually when data changes.

Use dontRemember() to force fresh reads

Bypass the cache for a specific query:

Future<void> dontRememberExample(DataSource dataSource) async {
final userId = 1;
final user = await dataSource.query<$User>().find(userId);

// Get fresh data, ignoring any cached results
final freshUser = await dataSource
.query<$User>()
.whereEquals('id', userId)
.dontRemember()
.first();

// Useful after updates
if (user != null) {
user.setAttribute('name', 'Updated');
await dataSource.repo<$User>().update(user);

final updated = await dataSource
.query<$User>()
.whereEquals('id', user.id)
.dontRemember()
.first();
}
}

Cache works with normal query chaining

Future<void> cacheChainingExample(DataSource dataSource) async {
// Complex query with caching
final premiumUsers = await dataSource
.query<$User>()
.whereEquals('active', true)
.orderBy('name')
.limit(100)
.remember(Duration(minutes: 10))
.get();

// With eager loading
final postsWithAuthors = await dataSource
.query<$Post>()
.with_(['author'])
.whereEquals('published', true)
.remember(Duration(minutes: 15))
.get();
}

2. Manage Cache Lifecycle

Flush all cache entries

Clear all cached queries:

Future<void> flushCacheExample(DataSource dataSource) async {
dataSource.context.flushQueryCache();

// Typically done after schema changes or deployment
// await runMigrations();
dataSource.context.flushQueryCache();
}

Vacuum expired entries

Remove expired cache entries to free memory:

Future<void> vacuumCacheExample(DataSource dataSource) async {
dataSource.context.vacuumQueryCache();

// Periodic vacuum
Timer.periodic(Duration(hours: 1), (_) {
dataSource.context.vacuumQueryCache();
});
}

Inspect cache statistics

Future<void> cacheStatsExample(DataSource dataSource) async {
final stats = dataSource.context.queryCacheStats;

print('Cache size: ${stats.size} entries');
print('Cache hits: ${stats.hits}');
print('Cache misses: ${stats.misses}');
print('Hit ratio: ${stats.hitRatio.toStringAsFixed(2)}%');

if (stats.hitRatio < 0.5) {
print('Warning: Low cache hit ratio: ${stats.hitRatio}');
}
}

3. Observe Cache Behavior

Real-time notifications about cache operations:

EventTriggerProperties
CacheHitEventResult found in cachesql, parameters, timestamp
CacheMissEventResult not in cachesql, parameters, timestamp
CacheStoreEventResult stored in cachesql, parameters, ttl, rowCount
CacheForgetEventEntry removedsql, parameters, timestamp
CacheFlushEventAll cache clearedtimestamp, entriesCleared
CacheVacuumEventExpired entries cleanedtimestamp, entriesRemoved

Listen to events

void cacheEventsListeningExample(DataSource dataSource) {
final subscription = dataSource.context.queryCache.listen((event) {
print('${event.eventName}: ${event.sql}');

if (event is CacheHitEvent) {
print('✅ Cache hit!');
} else if (event is CacheMissEvent) {
print('❌ Cache miss');
} else if (event is CacheStoreEvent) {
print('💾 Stored ${event.rowCount} rows');
}
});

// Remove listener
dataSource.context.queryCache.unlisten(subscription);
}
class CacheMonitor {
int hits = 0;
int misses = 0;

void startMonitoring(QueryContext context) {
context.queryCache.listen((event) {
if (event is CacheHitEvent)
hits++;
else if (event is CacheMissEvent)
misses++;
});
}

double get hitRatio {
final total = hits + misses;
return total > 0 ? hits / total : 0.0;
}

void report() {
print('Cache Performance:');
print(' Hits: $hits');
print(' Misses: $misses');
print(' Hit Ratio: ${(hitRatio * 100).toStringAsFixed(2)}%');
}
}

Integrate with metrics backends

void cacheMetricsIntegration(DataSource dataSource, Metrics metrics) {
dataSource.context.queryCache.listen((event) {
if (event is CacheHitEvent) {
metrics.increment('cache.hits');
} else if (event is CacheMissEvent) {
metrics.increment('cache.misses');
} else if (event is CacheStoreEvent) {
metrics.histogram('cache.stored_rows', event.rowCount);
}
});
}

// Placeholder for metrics interface
abstract class Metrics {
void increment(String name);
void histogram(String name, int value);
}

4. Choose Good Cache Targets

Good candidates:

  • Dashboard statistics and reports
  • Reference data (countries, categories)
  • User permissions and roles
  • Popular content (trending posts)
  • Aggregated data

Poor candidates:

  • Real-time data requiring immediate consistency
  • User-specific personalized content
  • Rapidly changing data (live scores, stock prices)
  • Write-heavy operations

5. Apply Practical Patterns

Select TTLs by volatility

Future<void> cacheBestPracticesTtl(DataSource dataSource) async {
// Short TTL for frequently changing data
final recentPosts = await dataSource
.query<$Post>()
.orderBy('createdAt', descending: true)
.limit(10)
.remember(Duration(minutes: 1))
.get();

// Longer TTL for stable data
final users = await dataSource
.query<$User>()
.remember(Duration(hours: 24))
.get();
}

Prioritize expensive queries

Future<void> cacheExpensiveQueries(DataSource dataSource) async {
// Complex aggregations
final stats = await dataSource
.query<$User>()
.selectRaw("DATE(created_at) as date, COUNT(*) as count")
.groupBy('date')
.remember(Duration(hours: 1))
.get();
}

Clear cache after schema/data-shape changes

Future<void> cacheClearOnSchema(DataSource dataSource) async {
// await runMigrations();
dataSource.context.flushQueryCache();
}

Avoid global caching for user-specific data

Future<void> cacheUserSpecificBad(
DataSource dataSource,
int currentUserId,
) async {
// BAD - caches for all users!
final userPosts = await dataSource
.query<$Post>()
.whereEquals('user_id', currentUserId)
.remember(Duration(minutes: 5))
.get();
}
Future<void> cacheUserSpecificGood(
DataSource dataSource,
int currentUserId,
) async {
// BETTER - don't cache user-specific data
final userPosts = await dataSource
.query<$Post>()
.whereEquals('user_id', currentUserId)
.get();
}

Monitor Cache Performance

void cachePerformanceMonitor(DataSource dataSource) {
dataSource.context.queryCache.listen((event) {
if (event is CacheMissEvent) {
print('Cache miss for: ${event.sql}');
}
});
}

6. Pick an Invalidation Strategy

Time-based (TTL)

Future<void> cacheTtlInvalidation(DataSource dataSource) async {
final data = await dataSource
.query<$User>()
.remember(Duration(minutes: 5))
.get();
}

Manual invalidation

Future<void> cacheManualInvalidation(DataSource dataSource, User user) async {
await dataSource.repo<$User>().update(user);
dataSource.context.flushQueryCache();
}

Event-driven invalidation

void cacheEventDrivenInvalidation(DataSource dataSource) {
dataSource.context.onMutation((event) {
if (event.affectedModels.contains('User')) {
dataSource.context.flushQueryCache();
}
});
}

Verify Your Setup

  • Re-running the same read query produces cache hits.
  • Write paths invalidate cache intentionally (flushQueryCache or event-driven hook).
  • Hit ratio is monitored so cache usage can be tuned over time.

Read This Next