Flutter Daily: Debouncing and Throttling User Input
Debouncing and Throttling User Input
What You’ll Learn
Learn how to control the rate of function execution in Flutter using debouncing and throttling techniques. These patterns prevent performance issues when handling rapid user input like search queries, scroll events, or button taps.
The Problem
When users type in a search box or scroll through a list, your app might trigger expensive operations (API calls, filtering large lists) on every single input event. This creates unnecessary network requests, UI lag, and poor user experience.
Debouncing waits for a pause in user input before executing the function. Perfect for search boxes—you want to wait until the user stops typing.
Throttling executes the function at most once per time interval, no matter how many events occur. Perfect for scroll handlers—you want regular updates but not hundreds per second.
Example: Debounced Search
Here’s a practical implementation of a debounced search field:
import 'dart:async';
import 'package:flutter/material.dart';
class DebouncedSearchField extends StatefulWidget {
const DebouncedSearchField({Key? key}) : super(key: key);
@override
State<DebouncedSearchField> createState() => _DebouncedSearchFieldState();
}
class _DebouncedSearchFieldState extends State<DebouncedSearchField> {
final TextEditingController _controller = TextEditingController();
Timer? _debounceTimer;
List<String> _searchResults = [];
bool _isSearching = false;
@override
void dispose() {
_debounceTimer?.cancel();
_controller.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
// Cancel the previous timer if it exists
_debounceTimer?.cancel();
// Create a new timer that will execute after 500ms
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) {
setState(() => _searchResults = []);
return;
}
setState(() => _isSearching = true);
// Simulate API call
await Future.delayed(const Duration(milliseconds: 300));
setState(() {
_searchResults = ['Result 1: $query', 'Result 2: $query', 'Result 3: $query'];
_isSearching = false;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _controller,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Search...',
suffixIcon: _isSearching
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2)
)
: const Icon(Icons.search),
),
),
Expanded(
child: ListView.builder(
itemCount: _searchResults.length,
itemBuilder: (context, index) {
return ListTile(title: Text(_searchResults[index]));
},
),
),
],
);
}
}
How it works:
- Every time the user types, we cancel any pending timer
- We create a new 500ms timer
- Only if the user stops typing for 500ms does the search execute
- This reduces API calls from potentially hundreds to just one per typing session
Example: Throttled Scroll Handler
Here’s a throttled scroll listener:
class ThrottledScrollExample extends StatefulWidget {
@override
State<ThrottledScrollExample> createState() => _ThrottledScrollExampleState();
}
class _ThrottledScrollExampleState extends State<ThrottledScrollExample> {
final ScrollController _scrollController = ScrollController();
DateTime? _lastExecutionTime;
final Duration _throttleDuration = const Duration(milliseconds: 200);
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final now = DateTime.now();
// Execute only if enough time has passed since last execution
if (_lastExecutionTime == null ||
now.difference(_lastExecutionTime!) > _throttleDuration) {
_lastExecutionTime = now;
_handleScrollUpdate(_scrollController.offset);
}
}
void _handleScrollUpdate(double offset) {
print('Scroll position: $offset');
// Perform expensive operations here
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
);
}
}
Try It Yourself
Create a search field that:
- Debounces user input with a 300ms delay
- Shows a loading indicator while searching
- Displays “No results” when the search returns empty
- Cancels any in-flight searches when a new query comes in (Hint: use a
CancelTokenif using dio, or check if the widget is still mounted)
Tip of the Day
Memory leak prevention: Always cancel timers in the dispose() method! Forgetting to cancel a Timer or StreamSubscription is a common source of memory leaks in Flutter apps. Use the pattern:
@override
void dispose() {
_debounceTimer?.cancel();
_controller.dispose();
super.dispose();
}
For more complex scenarios, consider using the rxdart package which provides debounceTime() and throttleTime() operators for streams, making these patterns even easier to implement.