Flutter Data Grid Production Deployment with VooStack
Your Flutter data grid works perfectly in development with 100 rows of test data. Then you deploy to production and users start loading tables with 50,000 customer records. Suddenly scrolling stutters, memory usage spikes to 800MB, and your app becomes unusable.
We've seen this pattern dozens of times at AgileStack when helping teams deploy enterprise Flutter applications. The gap between development and production for data-heavy interfaces is brutal. Here's how to bridge it.
The Production Reality Check
Most Flutter data grid tutorials focus on basic implementation. They show you how to create columns, handle sorting, maybe add some basic filtering. But production brings different challenges.
Last month we worked with a logistics company migrating their shipment tracking system to Flutter. Their data grid worked fine in testing with a few hundred shipments. In production, warehouse managers needed to scroll through 25,000+ active shipments without the app freezing.
The initial implementation loaded all data at startup and kept it in memory. Memory usage hit 1.2GB on older Android devices. The app crashed regularly.
Virtual Scrolling and Lazy Loading
The first fix was implementing proper virtual scrolling. Instead of rendering all 25,000 rows, we only render what's visible plus a small buffer.
class ProductionDataGrid extends StatefulWidget {
final DataGridController controller;
final int visibleRowCount;
final double rowHeight;
const ProductionDataGrid({
required this.controller,
this.visibleRowCount = 20,
this.rowHeight = 56.0,
});
}
class _ProductionDataGridState extends State<ProductionDataGrid> {
final ScrollController _scrollController = ScrollController();
late int _firstVisibleIndex;
late int _lastVisibleIndex;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_calculateVisibleRange();
}
void _onScroll() {
final offset = _scrollController.offset;
final firstIndex = (offset / widget.rowHeight).floor();
final lastIndex = firstIndex + widget.visibleRowCount + 2; // Buffer
if (firstIndex != _firstVisibleIndex || lastIndex != _lastVisibleIndex) {
setState(() {
_firstVisibleIndex = firstIndex;
_lastVisibleIndex = lastIndex;
});
// Trigger data loading for new range
widget.controller.loadRange(_firstVisibleIndex, _lastVisibleIndex);
}
}
}
This dropped memory usage from 1.2GB to under 200MB for the same dataset. Scroll performance improved from 15fps to solid 60fps on mid-range Android devices.
Caching Strategy for Enterprise Data
Virtual scrolling helps with rendering, but you still need smart caching for data fetching. We implemented a three-tier caching approach:
- Hot cache: Recently viewed rows (500-1000 items max)
- Warm cache: Prefetched adjacent data based on scroll direction
- Cold storage: Disk cache for previously loaded pages
class DataGridCache {
final Map<int, GridRow> _hotCache = {};
final Map<int, GridRow> _warmCache = {};
final int _maxHotCacheSize;
DataGridCache({int maxHotSize = 1000}) : _maxHotCacheSize = maxHotSize;
Future<List<GridRow>> getRows(int startIndex, int count) async {
final rows = <GridRow>[];
final missingIndices = <int>[];
// Check hot cache first
for (int i = startIndex; i < startIndex + count; i++) {
if (_hotCache.containsKey(i)) {
rows.add(_hotCache[i]!);
} else if (_warmCache.containsKey(i)) {
// Promote from warm to hot
final row = _warmCache.remove(i)!;
_hotCache[i] = row;
rows.add(row);
} else {
missingIndices.add(i);
}
}
// Fetch missing data
if (missingIndices.isNotEmpty) {
final newRows = await _fetchFromServer(missingIndices);
for (int i = 0; i < newRows.length; i++) {
final index = missingIndices[i];
_hotCache[index] = newRows[i];
rows.add(newRows[i]);
}
}
_evictOldEntries();
return rows;
}
void _evictOldEntries() {
if (_hotCache.length > _maxHotCacheSize) {
// Move oldest entries to warm cache or remove entirely
final sortedKeys = _hotCache.keys.toList()..sort();
final toEvict = sortedKeys.take(_hotCache.length - _maxHotCacheSize);
for (final key in toEvict) {
final row = _hotCache.remove(key)!;
if (_warmCache.length < 2000) {
_warmCache[key] = row;
}
}
}
}
}
State Management at Scale
For enterprise data grids, state management becomes critical. You're juggling:
- Current data set and filters
- Sort state across multiple columns
- Selection state for bulk operations
- Loading states for different data ranges
- Error states and retry logic
We use a modified BLoC pattern specifically for data grids:
class DataGridBloc extends Cubit<DataGridState> {
final DataGridRepository _repository;
final DataGridCache _cache;
DataGridBloc(this._repository, this._cache) : super(DataGridState.initial());
Future<void> loadRange(int start, int count) async {
if (state.isLoadingRange(start, count)) return; // Prevent duplicate requests
emit(state.copyWith(loadingRanges: {...state.loadingRanges, RangeKey(start, count)}));
try {
final rows = await _cache.getRows(start, count);
emit(state.copyWith(
rows: {...state.rows, for (int i = 0; i < rows.length; i++) start + i: rows[i]},
loadingRanges: state.loadingRanges..remove(RangeKey(start, count)),
));
} catch (e) {
emit(state.copyWith(
errors: {...state.errors, RangeKey(start, count): e.toString()},
loadingRanges: state.loadingRanges..remove(RangeKey(start, count)),
));
}
}
void applyFilter(String column, FilterCriteria criteria) {
_cache.clear(); // Filters invalidate cache
emit(state.copyWith(
filters: {...state.filters, column: criteria},
rows: {}, // Clear existing data
));
// Reload current visible range with new filters
loadRange(state.firstVisibleIndex, state.visibleCount);
}
}
Performance Monitoring and Metrics
You can't optimize what you don't measure. We instrument our production data grids with specific metrics:
- Scroll performance: Frame rate during scrolling
- Memory usage: Peak and average memory consumption
- Cache hit rates: Hot/warm/cold cache effectiveness
- Network requests: Number and timing of data fetches
- User interactions: Time to first meaningful scroll, filter application time
class DataGridMetrics {
static const _scrollPerformanceKey = 'data_grid_scroll_fps';
static const _cacheHitRateKey = 'data_grid_cache_hit_rate';
static const _memoryUsageKey = 'data_grid_memory_mb';
static void trackScrollPerformance(double fps) {
FirebasePerformance.instance.newCustomTrace('data_grid_scroll')
..putAttribute('fps', fps.toString())
..start()
..stop();
}
static void trackCachePerformance(int hits, int total) {
final hitRate = (hits / total * 100).round();
FirebasePerformance.instance.newCustomTrace('data_grid_cache')
..putAttribute('hit_rate', hitRate.toString())
..start()
..stop();
}
}
After implementing monitoring, we discovered that cache hit rates below 85% correlated strongly with user complaints about slow scrolling. This led us to improve our prefetching algorithm.
Error Handling and Recovery
Production data grids face network timeouts, server errors, and partial data loads. Your error handling needs to be surgical, not nuclear.
class DataGridErrorHandler {
static Widget buildErrorState(DataGridError error, VoidCallback onRetry) {
switch (error.type) {
case DataGridErrorType.networkTimeout:
return _buildRetryableError(
'Connection timeout loading data',
'Tap to retry',
onRetry,
);
case DataGridErrorType.serverError:
return _buildServerError(error.statusCode, onRetry);
case DataGridErrorType.partialLoad:
return _buildPartialLoadError(error.loadedCount, error.totalCount, onRetry);
default:
return _buildGenericError(onRetry);
}
}
static Widget _buildPartialLoadError(int loaded, int total, VoidCallback onRetry) {
return Card(
color: Colors.orange.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 12),
Expanded(
child: Text('Loaded $loaded of $total rows. Some data may be missing.'),
),
TextButton(onPressed: onRetry, child: Text('Retry')),
],
),
),
);
}
}
Key Production Considerations
- Memory management: Virtual scrolling is non-negotiable for large datasets. Implement proper cache eviction.
- Network efficiency: Batch requests and implement smart prefetching based on scroll patterns.
- Error boundaries: Partial failures shouldn't break the entire grid. Show what you can, retry what failed.
- Performance monitoring: Instrument scroll fps, memory usage, and cache performance from day one.
- Progressive loading: Show skeleton screens for loading states, not blank spaces.
- Accessibility: Screen readers need proper semantics for large data tables.
The logistics company's Flutter data grid now handles 50,000+ rows smoothly. Memory usage stays under 250MB, scroll performance is consistent at 60fps, and cache hit rates average 92%. Most importantly, warehouse managers can actually use it to get work done.
Start with virtual scrolling and proper caching. Everything else builds on that foundation. Your future self will thank you when production data loads start hitting your development assumptions.