Essentials

Response Caching

Cache API responses and reduce network calls with FutureShard's built-in caching.

Overview

FutureShard includes built-in response caching to reduce network calls and improve app performance. Unlike PersistentShard which persists state across app sessions, response caching stores API responses temporarily in memory or disk to avoid redundant network requests.

Key Differences

FeaturePersistentShardResponse Caching
PurposePersist app state across sessionsCache API responses temporarily
StorageLong-term (survives app restarts)Short-term (cleared on app restart with memory cache)
Use CaseUser preferences, app settings, todosAPI responses, fetched data
TTLNo expiration (until cleared)Configurable expiration time

Enabling Caching

Caching is enabled by default in FutureShard. Customize the cache key or expiration:

class UserShard extends FutureShard<User> {
  final String userId;
  final UserRepository repository;

  UserShard({
    required this.userId,
    required this.repository,
  });

  // Optional: customize cache key
  @override
  String get cacheKey => 'user_$userId';

  // Optional: customize cache expiration
  @override
  Duration get cacheTTL => const Duration(hours: 1);

  @override
  Future<User> build() async {
    return await repository.getUser(userId);
  }
}

To disable caching, override allowCache:

@override
bool get allowCache => false;

The data is cached directly as-is, without any serialization. This works seamlessly with MemoryCacheService and any cache service that can store Dart objects directly.

How It Works

  1. First Request: When FutureShard fetches data, it saves the response to cache
  2. Subsequent Requests: If cached data exists and hasn't expired, it's returned immediately without a network call
  3. Cache Expiration: After cacheTTL duration, cached data is considered stale and fresh data is fetched
  4. Manual Refresh: Calling refresh() invalidates the cache and fetches fresh data

Configuration Options

Cache Key

cacheKey defaults to the shard's runtime type name (runtimeType.toString()). Override it to provide a unique identifier for your cached data:

@override
String get cacheKey => 'product_${productId}_${locale}';

Override cacheKey for parameterized shards. Because the default key is just the type name, every instance of the same shard type shares one cache entry. Two UserShards with different userIds would read and write the same entry and serve each other's data—unless you override cacheKey to include the parameters that distinguish one instance from another.

The default also relies on runtimeType.toString(), which is not stable under release-build obfuscation or tree-shaking. If a shard's cache must survive obfuscation (especially a disk cache), override cacheKey with an explicit literal instead of relying on the type name.

Cache TTL

Cached data is considered valid for cacheTTL, which defaults to 1 hour. Override it to set how long cached data should be considered valid:

@override
Duration get cacheTTL => const Duration(minutes: 30); // 30 minutes

Custom Cache Service

By default, FutureShard uses MemoryCacheService (in-memory cache). Provide a custom cache service for persistent caching:

class ProductShard extends FutureShard<Product> {
  final CacheService _cacheService;

  ProductShard({
    required this._cacheService,
    required String productId,
  }) : _productId = productId;

  final String _productId;

  @override
  bool get allowCache => true;

  @override
  CacheService get cacheService => _cacheService;

  @override
  String get cacheKey => 'product_$_productId';

  // ... rest of implementation
}

Cache Service Implementations

Memory Cache (Default)

MemoryCacheService stores data in memory. Data is lost when the app restarts:

@override
CacheService get cacheService => MemoryCacheService();

Bounding the memory cache

By default MemoryCacheService is unbounded. Pass maxEntries to cap how many entries it retains; once the count exceeds the limit, the oldest entries are evicted on the next write:

// Cap retained entries; oldest are evicted on write past the limit (default: unbounded).
final cache = MemoryCacheService(maxEntries: 100);

cache.entryCount;          // entries currently held
final removed = cache.evictExpired(); // drop expired entries on demand; returns count
read still returns expired entries, so stale-while-revalidate flows (CacheMixin's onErrorReturnOldCache) keep working.

Disk Cache (Custom)

Create a persistent cache service using SharedPreferences or other storage:

class SharedPrefsCacheService implements CacheService {
  final SharedPreferences _prefs;

  SharedPrefsCacheService(this._prefs);

  @override
  Future<void> write(String key, CacheEntry entry) async {
    await _prefs.setString(key, jsonEncode(entry.toJson()));
  }

  @override
  Future<CacheEntry?> read(String key) async {
    final json = _prefs.getString(key);
    if (json == null) return null;
    return CacheEntry.fromJson(jsonDecode(json));
  }

  @override
  Future<void> delete(String key) async {
    await _prefs.remove(key);
  }

  @override
  Future<void> clearAll() async {
    await _prefs.clear();
  }
}

Using CacheMixin in Repositories

You can also use caching directly in your repositories using CacheMixin. This is useful when you want to cache data outside of FutureShard:

import 'package:shard/shard.dart';

class ProductRepository with CacheMixin {
  final ProductApi _api;

  ProductRepository(this._api);

  @override
  CacheService get cacheService => MemoryCacheService();

  Future<Product> getProduct(String id) {
    return resolve<Product>(
      key: 'product_$id',
      fetcher: () => _api.fetchProduct(id),
      ttl: const Duration(hours: 2),
      forceRefresh: false,
      onErrorReturnOldCache: true, // Fallback to stale data on error
    );
  }

  Future<List<Product>> searchProducts(String query) {
    return resolve<List<Product>>(
      key: 'products_search_$query',
      fetcher: () => _api.searchProducts(query),
      ttl: const Duration(minutes: 15),
    );
  }

  // Force refresh a specific product
  Future<Product> refreshProduct(String id) {
    return resolve<Product>(
      key: 'product_$id',
      fetcher: () => _api.fetchProduct(id),
      forceRefresh: true, // Bypass cache
    );
  }
}

Note: When using CacheMixin in repositories, the repository's cache and FutureShard's cache are separate. Choose one approach based on your needs:

  • Use FutureShard caching for automatic cache management tied to shard lifecycle
  • Use CacheMixin in repositories for more granular control or when caching outside of shards

Cache logging

CacheMixin can log cache hits, misses, and errors to help you debug caching behavior. Override logCacheEvents to toggle logging, and onCacheLog to redirect log lines to your own logger:

class ProductRepository with CacheMixin {
  @override
  CacheService get cacheService => MemoryCacheService();

  // Toggle cache hit/miss/error logging (defaults to kDebugMode):
  @override
  bool get logCacheEvents => true;

  // Redirect log lines (default sink: dart:developer.log(name: 'shard.cache')):
  @override
  void onCacheLog(String message) => myLogger.debug(message);
}
Logging is gated off in release by default, so cache keys (which may contain user identifiers) are not emitted to release logs.

Manual Cache Control

Refresh with Cache Invalidation

Call refresh() to invalidate cache and fetch fresh data:

final userShard = context.read<UserShard>();
userShard.refresh(); // Invalidates cache and fetches fresh data

Refresh Without Cache Invalidation

Keep cache but force a fresh fetch:

userShard.refresh(invalidateCache: false);

Clear Specific Cache Entry

Manually delete a cache entry:

final cacheService = MemoryCacheService();
await cacheService.delete('user_123');

Complete Example

class ProductShard extends FutureShard<Product> {
  final String productId;
  final ProductRepository repository;
  final CacheService _cache;

  ProductShard({
    required this.productId,
    required this.repository,
    CacheService? cacheService,
  }) : _cache = cacheService ?? MemoryCacheService();

  @override
  bool get allowCache => true;

  @override
  CacheService get cacheService => _cache;

  @override
  String get cacheKey => 'product_$productId';

  @override
  Duration get cacheTTL => const Duration(hours: 2);

  @override
  Future<Product> build() async {
    return await repository.getProduct(productId);
  }
}

Best Practices

  1. Use Appropriate TTL: Set cache TTL based on how often your data changes
    • Frequently changing data: 5-15 minutes
    • Moderately changing data: 1-2 hours
    • Rarely changing data: 24 hours or more
  2. Unique Cache Keys: Ensure cache keys are unique per data instance
    String get cacheKey => 'user_${userId}_${locale}';
    
  3. Error Handling: Cache read/write failures don't break your app - they're handled gracefully
  4. Memory vs Disk: Use memory cache for temporary data, disk cache for data that should survive app restarts
  5. Cache Invalidation: Use refresh() when user explicitly requests fresh data (pull-to-refresh)

When to Use Response Caching

Good for:

  • API responses that don't change frequently
  • Reducing network calls and improving performance
  • Offline-first experiences (with disk cache)
  • Reducing server load

Not ideal for:

  • Real-time data (use StreamShard instead)
  • Frequently changing data
  • Data that must always be fresh
  • User-generated content that needs immediate sync

Next Steps

Next: Observing a Shard to monitor state changes, errors, and lifecycle events. See also: Persistent Shard.