Pole Inventory Caching Implementation
Overview
The BPVA Calendar app implements intelligent caching for pole inventory data to significantly reduce Firestore read operations and improve app performance. This caching strategy reduces Firestore costs by 80-90% for typical usage patterns while maintaining data accuracy and real-time updates where needed.
Problem Statement
Before Caching
The original PoleInventoryService used real-time Firestore streams:
Stream<List<Pole>> getPoles() {
return _firestore
.collection('poles')
.orderBy('brand')
.orderBy('weight')
.snapshots() // Real-time listener
.map(...)
}
Cost Analysis (for a typical gym with 100 poles, 20 active checkouts, 500 checkout history records):
- Every screen open: 620 reads (100 poles + 20 active + 500 history)
- Admin checks screen 10x/day: 6,200 reads/day from one user
- Real-time updates: Every data change triggers additional reads for all active listeners
The Issue
- Pole data rarely changes - Adding/removing poles is infrequent (weekly at most)
- Checkout history never changes - Historical records are immutable
- Active checkouts change moderately - Only when poles are checked in/out
- Multiple screen visits - Users often refresh the screen unnecessarily
- Firestore caching not sufficient - Built-in cache doesn’t prevent new listener reads
Solution: CachedPoleInventoryService
Architecture
The CachedPoleInventoryService wraps the existing PoleInventoryService with intelligent caching:
┌─────────────────────────────────────┐
│ PoleInventoryScreen │
│ BulkCheckoutScreen │
│ PoleImportUtil │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ CachedPoleInventoryService │
│ - TTL-based caching │
│ - Automatic cache invalidation │
│ - Smart refresh logic │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ PoleInventoryService │
│ - Direct Firestore access │
└─────────────────────────────────────┘
Caching Strategy
1. Poles Collection
- Cache TTL: 10 minutes
- Rationale: Poles are added/removed infrequently
- Invalidation: Automatic on add/update/delete operations
- Behavior: Returns cached data if valid, otherwise fetches from Firestore
2. Active Checkouts
- No caching - Real-time stream maintained
- Rationale: Changes frequently, needs immediate updates
- Behavior: Always uses Firestore real-time listener
3. Checkout History
- Cache TTL: 1 hour
- Rationale: Historical data is immutable (records never change)
- Invalidation: Manual refresh or delete operations only
- Behavior: Returns cached data if valid, otherwise fetches from Firestore
Key Features
Automatic Cache Invalidation
The service automatically invalidates and refreshes caches when data changes:
Future<String> addPole({...}) async {
final result = await _poleService.addPole(...);
invalidatePoleCache();
_fetchPolesFromFirestore(); // Auto-refresh
return result;
}
Operations that trigger cache invalidation:
- Pole cache:
addPole(),updatePole(),deletePole(),checkoutPole(),returnPole() - History cache:
deleteCheckoutRecord()
Manual Refresh
Users can force refresh all data:
- Refresh button in AppBar - Forces cache refresh
- Pull-to-refresh on all three tabs - Reloads data with
forceRefresh: true
Future<void> _loadData({bool forceRefresh = false}) async {
_poleInventoryService.getPoles(forceRefresh: forceRefresh).listen(...);
_poleInventoryService.getCheckoutHistory(forceRefresh: forceRefresh).listen(...);
}
Smart Query Optimization
The service optimizes queries by checking cache before hitting Firestore:
Future<Pole?> getPoleById(String poleId) async {
// Check cache first
if (_isPolesCacheValid && _cachedPoles != null) {
try {
return _cachedPoles!.firstWhere((pole) => pole.id == poleId);
} catch (e) {
// Not found in cache, fetch from Firestore
}
}
return await _poleService.getPoleById(poleId);
}
Implementation Details
File: lib/services/cached_pole_inventory_service.dart
Key Components:
class CachedPoleInventoryService {
// Cache data
List<Pole>? _cachedPoles;
DateTime? _polesLastFetched;
List<PoleCheckout>? _cachedHistory;
DateTime? _historyLastFetched;
// Cache TTL constants
static const Duration _polesCacheTTL = Duration(minutes: 10);
static const Duration _historyCacheTTL = Duration(hours: 1);
// Cache validation
bool get _isPolesCacheValid {
if (_cachedPoles == null || _polesLastFetched == null) return false;
return DateTime.now().difference(_polesLastFetched!) < _polesCacheTTL;
}
}
Debug Logging
The service provides comprehensive debug logging to track cache behavior:
🚀 Returning cached poles (100 items)
📡 Fetching poles from Firestore (cache expired)
✅ Poles cached (100 items)
🗑️ Invalidating pole cache
🔄 Force refreshing all cached data
Modified Files
lib/services/cached_pole_inventory_service.dart(NEW)- 330 lines
- Complete caching wrapper with TTL logic
- Automatic invalidation
- Debug logging
lib/screens/admin/pole_inventory_screen.dart- Changed:
PoleInventoryService→CachedPoleInventoryService - Added:
forceRefreshparameter to_loadData() - Added:
RefreshIndicatorto all three tabs - Updated: Refresh button to use
forceRefresh: true
- Changed:
lib/screens/admin/bulk_checkout_screen.dart- Changed:
PoleInventoryService→CachedPoleInventoryService - Benefits from automatic cache invalidation on checkout/checkin
- Changed:
lib/utils/pole_import_util.dart- Changed:
PoleInventoryService→CachedPoleInventoryService - Benefits from automatic cache invalidation on pole import
- Changed:
Performance Impact
Read Reduction Analysis
Scenario: Admin opens pole inventory screen 10 times in one day
| Operation | Without Cache | With Cache | Savings |
|---|---|---|---|
| Screen open #1 | 620 reads | 620 reads | 0% |
| Screen open #2 (5 min later) | 620 reads | 0 reads | 100% |
| Screen open #3 (5 min later) | 620 reads | 0 reads | 100% |
| Screen open #4 (15 min later) | 620 reads | 100 reads* | 84% |
| Screen open #5-10 | 3,720 reads | Variable** | ~85% |
| Total Daily | 6,200 reads | ~900 reads* | 85% |
* Pole cache expired (10 min), history cache still valid (1 hour)
** Depends on time between opens and cache expiration
*** Estimated average with typical usage patterns
Cost Savings
Firestore Pricing (as of 2024):
- Document reads: $0.06 per 100,000 reads
- Daily savings (10 opens): 5,300 reads = $0.00318/day
- Monthly savings (single admin): ~$0.10/month
- For 5 active admins: ~$0.50/month
- For 20 active coaches/admins: ~$2.00/month
While individual savings seem small, this scales significantly with:
- More users accessing the inventory
- Larger inventories (more poles = more reads)
- More frequent screen opens
User Experience Benefits
- Faster load times - Cached data loads instantly
- Offline support - Works with Firestore offline persistence
- Reduced network usage - Less data transfer on mobile devices
- Battery savings - Fewer network requests
- Smoother UI - No loading spinners on cache hits
Usage Guidelines
For Developers
When to use force refresh:
- User manually clicks refresh button
- User pulls down to refresh
- After critical operations (not needed - auto-invalidation handles this)
When NOT to use force refresh:
- On every screen navigation
- On every tab change
- On timer-based auto-refresh
Testing cache behavior:
// In tests, use the .test() constructor
final cachedService = CachedPoleInventoryService.test(
poleService: mockPoleService,
);
For Users
To get latest data:
- Pull down on any tab (pull-to-refresh)
- Tap refresh icon in top-right corner
- Wait for cache to expire (10 minutes for poles, 1 hour for history)
Cache indicators:
- Debug console shows cache hits/misses (development only)
- No visual indicator to end users (seamless experience)
Testing
Manual Testing Checklist
- Open pole inventory screen
- Check debug console for “📡 Fetching poles from Firestore”
- Navigate away and return within 10 minutes
- Check debug console for “🚀 Returning cached poles”
- Add a new pole
- Verify cache invalidation and auto-refresh
- Pull down to refresh
- Verify force refresh bypasses cache
- Wait 11 minutes and return to screen
- Verify cache expired and data re-fetched
Expected Debug Output
📡 Fetching poles from Firestore (cache force refresh)
📡 Using real-time stream for active checkouts (no cache)
📡 Fetching checkout history from Firestore (cache force refresh)
✅ Poles cached (100 items)
✅ Checkout history cached (500 items)
🗑️ Invalidating pole cache
📡 Fetching poles from Firestore (cache expired)
✅ Poles cached (101 items)
Future Enhancements
Potential Improvements
- Persistent cache - Save cache to disk for app restart persistence
- Background refresh - Refresh cache in background when near expiration
- Selective invalidation - Only invalidate specific items instead of entire cache
- Cache statistics - Track cache hit/miss rates for analytics
- Configurable TTL - Allow admins to configure cache duration via settings
- Network-aware caching - Longer TTL on cellular, shorter on WiFi
Considerations
- Memory usage - Currently keeps full cache in memory (acceptable for <1000 items)
- Stale data risk - 10-minute cache means potential 10-minute delay for external updates
- Multi-device sync - Cache doesn’t sync across devices (each has own cache)
Troubleshooting
Issue: Data not updating
Symptoms: Changes made on one device not visible on another
Cause: Each device has independent cache
Solution:
- Pull to refresh on the second device
- Wait for cache to expire (10 minutes)
- Changes will auto-update when cache expires
Issue: High read count persists
Symptoms: Firestore usage hasn’t decreased after implementing caching
Cause: Active checkouts still use real-time stream (intentional)
Solution: This is expected behavior. Verify reduction in poles/history reads specifically.
Issue: Cache never expires
Symptoms: Debug console always shows cached data
Cause: User opening screen frequently (within TTL window)
Solution: This is correct behavior - working as intended!
Related Documentation
- Pole Inventory Feature - Main feature documentation
- Bulk Checkout Feature - Bulk checkout/checkin system
- Service Costs and Limits - Firebase pricing information
Version History
| Date | Version | Changes |
|---|---|---|
| 2024-11-27 | 1.0.0 | Initial caching implementation with TTL-based strategy |
Author: GitHub Copilot
Last Updated: November 27, 2024
Status: Production Ready