Answer
Overview
Debouncing is a technique to delay execution of a function until a certain amount of time has passed since the last call. It's commonly used to avoid excessive API calls when user types in a search field.
Problem Without Debouncing
Without debouncing, every keystroke triggers an API call.
dartTextField( onChanged: (query) { searchAPI(query); // ❌ Called on EVERY keystroke! }, )
Example: User types "flutter"
- → API calltext
f - → API calltext
fl - → API calltext
flu - → API calltext
flut - → API calltext
flutt - → API calltext
flutte - → API calltext
flutter
Result: 7 API calls! (wasteful, slow, expensive)
Solution: Debouncing
Wait 300-500ms after user stops typing, then make API call.
dartTextField( onChanged: (query) { debounce(() { searchAPI(query); // ✅ Called only after 500ms of no typing }); }, )
Example: User types "flutter"
- ,text
f,textfl,textflu,textflut,textflutt,textflutte→ (user stops typing)textflutter - Wait 500ms
- → API call with "flutter"
Result: 1 API call! ✅
Implementation
Method 1: Using Timer
dartimport 'dart:async'; class SearchScreen extends StatefulWidget { _SearchScreenState createState() => _SearchScreenState(); } class _SearchScreenState extends State<SearchScreen> { Timer? _debounce; void _onSearchChanged(String query) { // Cancel previous timer if (_debounce?.isActive ?? false) _debounce!.cancel(); // Start new timer _debounce = Timer(Duration(milliseconds: 500), () { // Execute after 500ms of no typing searchAPI(query); }); } void dispose() { _debounce?.cancel(); // Cleanup super.dispose(); } Widget build(BuildContext context) { return TextField( onChanged: _onSearchChanged, decoration: InputDecoration( labelText: 'Search', prefixIcon: Icon(Icons.search), ), ); } Future<void> searchAPI(String query) async { print('Searching for: $query'); // API call here } }
Method 2: Reusable Debounce Class
dartimport 'dart:async'; class Debouncer { final Duration duration; Timer? _timer; Debouncer({this.duration = const Duration(milliseconds: 500)}); void run(VoidCallback action) { _timer?.cancel(); _timer = Timer(duration, action); } void dispose() { _timer?.cancel(); } } // Usage class SearchScreen extends StatefulWidget { _SearchScreenState createState() => _SearchScreenState(); } class _SearchScreenState extends State<SearchScreen> { final _debouncer = Debouncer(duration: Duration(milliseconds: 500)); void dispose() { _debouncer.dispose(); super.dispose(); } Widget build(BuildContext context) { return TextField( onChanged: (query) { _debouncer.run(() { searchAPI(query); }); }, ); } Future<void> searchAPI(String query) async { print('Searching for: $query'); } }
Method 3: Using Stream (RxDart)
yamldependencies: rxdart: ^0.27.0
dartimport 'package:rxdart/rxdart.dart'; class SearchScreen extends StatefulWidget { _SearchScreenState createState() => _SearchScreenState(); } class _SearchScreenState extends State<SearchScreen> { final _searchController = TextEditingController(); final _searchSubject = PublishSubject<String>(); void initState() { super.initState(); // Debounce search stream _searchSubject.stream .debounceTime(Duration(milliseconds: 500)) .listen((query) { searchAPI(query); }); } void dispose() { _searchController.dispose(); _searchSubject.close(); super.dispose(); } Widget build(BuildContext context) { return TextField( controller: _searchController, onChanged: (query) { _searchSubject.add(query); // Add to stream }, decoration: InputDecoration( labelText: 'Search', prefixIcon: Icon(Icons.search), ), ); } Future<void> searchAPI(String query) async { print('Searching for: $query'); } }
Method 4: Using BLoC
dartimport 'package:flutter_bloc/flutter_bloc.dart'; // Event class SearchQueryChanged extends SearchEvent { final String query; SearchQueryChanged(this.query); } // BLoC class SearchBloc extends Bloc<SearchEvent, SearchState> { SearchBloc() : super(SearchInitial()) { on<SearchQueryChanged>( _onSearchQueryChanged, transformer: debounce(Duration(milliseconds: 500)), ); } Future<void> _onSearchQueryChanged( SearchQueryChanged event, Emitter<SearchState> emit, ) async { emit(SearchLoading()); try { final results = await searchAPI(event.query); emit(SearchSuccess(results)); } catch (e) { emit(SearchError(e.toString())); } } } // Debounce transformer EventTransformer<T> debounce<T>(Duration duration) { return (events, mapper) => events.debounceTime(duration).flatMap(mapper); }
Complete Example: Search Screen
dartimport 'dart:async'; import 'package:flutter/material.dart'; class SearchScreen extends StatefulWidget { _SearchScreenState createState() => _SearchScreenState(); } class _SearchScreenState extends State<SearchScreen> { Timer? _debounce; List<String> _results = []; bool _isLoading = false; void _onSearchChanged(String query) { if (_debounce?.isActive ?? false) _debounce!.cancel(); setState(() { _isLoading = true; }); _debounce = Timer(Duration(milliseconds: 500), () async { if (query.isEmpty) { setState(() { _results = []; _isLoading = false; }); return; } // Simulate API call final results = await searchAPI(query); setState(() { _results = results; _isLoading = false; }); }); } void dispose() { _debounce?.cancel(); super.dispose(); } Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Search')), body: Column( children: [ Padding( padding: EdgeInsets.all(16), child: TextField( onChanged: _onSearchChanged, decoration: InputDecoration( labelText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder(), ), ), ), if (_isLoading) CircularProgressIndicator() else Expanded( child: ListView.builder( itemCount: _results.length, itemBuilder: (context, index) { return ListTile( title: Text(_results[index]), ); }, ), ), ], ), ); } Future<List<String>> searchAPI(String query) async { await Future.delayed(Duration(milliseconds: 500)); // Simulate network delay return List.generate(10, (i) => '$query - Result $i'); } }
Debouncing vs Throttling
| Technique | Behavior | Use Case |
|---|---|---|
| Debouncing | Waits until user stops action | Search input (wait for user to stop typing) |
| Throttling | Executes at fixed intervals | Scroll events (execute every 200ms during scroll) |
Throttling Example
dartTimer? _throttle; void _onScroll() { if (_throttle?.isActive ?? false) return; // Skip if already running _throttle = Timer(Duration(milliseconds: 200), () { print('Scroll event executed'); }); }
Debounce Duration
| Duration | Use Case |
|---|---|
| 200-300ms | Fast interactions (autocomplete) |
| 500ms | Standard search (most common) |
| 1000ms+ | Expensive operations (complex calculations) |
Best Practices
dart// ✅ Use debouncing for search inputs TextField(onChanged: (query) => _debouncer.run(() => search(query))) // ✅ Cancel timers in dispose void dispose() { _debounce?.cancel(); super.dispose(); } // ✅ Use reasonable delay (500ms for search) Duration(milliseconds: 500) // ❌ Don't debounce instant actions (button clicks) // Buttons should respond immediately // ❌ Don't use too long delay (poor UX) // Duration(seconds: 5) ❌ Too long!
Common Use Cases
- Search fields — Wait for user to stop typing
- Autocomplete — Suggest results after delay
- API calls — Avoid excessive requests
- Form validation — Validate after user finishes input
- Window resize — Recalculate layout after resize stops
Summary
| Technique | Trigger | Example |
|---|---|---|
| Debouncing | After delay since last event | Search input (wait 500ms after typing stops) |
| Throttling | At fixed intervals | Scroll listener (every 200ms) |
Key Takeaway: Debouncing improves performance by reducing unnecessary function calls (especially API calls).
Learn more: Debouncing in Flutter