How to Prevent Duplicate API Requests in Flutter (Debounce, Idempotency, and Queue Patterns)
A user taps "Place Order." The network is slow. Nothing happens. They tap again. And again. Three orders show up. You refund two of them, apologize, and wonder how this made it to production.
Or: a user pulls to refresh, and while the data is loading, they pull again. Now two identical API calls are racing, both updating the same state, and the UI flickers between old and new data.
Duplicate requests are one of those problems that never happen in development — because your local network is fast and you test like a patient, methodical human. Real users on 3G networks are neither patient nor methodical.
Why Duplicates Happen
There are three main causes:
- Double-taps — User taps a button twice because the first tap didn't produce visible feedback fast enough
- Retry storms — App retries a failed request, but the original request actually went through (the response just arrived late)
- Concurrent refreshes — Pull-to-refresh, pagination, or search triggers overlap with an in-flight request
The fix isn't one thing — it's a combination of client-side guards and server-side safety nets.
Mistake 1: Disabling Buttons With setState Alone
// ❌ Race condition — setState is async
bool _isLoading = false;
Future<void> _placeOrder() async {
if (_isLoading) return;
setState(() => _isLoading = true);
// Two rapid taps can both pass the `if` check
// before setState rebuilds
await api.post('/orders', data: orderData);
setState(() => _isLoading = false);
}setState triggers an async rebuild. If two taps fire in the same frame, both can read _isLoading as false before either sets it to true.
Mistake 2: Using Debounce Wrong
// ❌ Debounce delays the action — but doesn't prevent duplicates
Timer? _debounce;
void _onTap() {
_debounce?.cancel();
_debounce = Timer(Duration(milliseconds: 300), () {
_placeOrder(); // Still fires if tapped again after 300ms
});
}Debounce is for search input, not button taps. Users expect a button to respond immediately on the first tap, not after a 300ms delay.
Mistake 3: Ignoring the Server Side
Even with perfect client-side guards, network issues can cause duplicates. A request succeeds on the server, but the response times out on the client. The app retries, and now the server processes the same action twice.
Solution 1: Synchronous Guard with Completer
For button taps, use a Completer or a simple boolean with synchronous checking:
bool _inFlight = false;
Future<void> _placeOrder() async {
// Synchronous check — no race condition possible
if (_inFlight) return;
_inFlight = true;
setState(() {}); // Update UI to show loading
try {
await api.post('/orders', data: orderData);
// Handle success
} catch (e) {
// Handle error
} finally {
_inFlight = false;
if (mounted) setState(() {});
}
}The key difference: _inFlight = true is set synchronously before any await. Two taps in the same frame will both enter _placeOrder, but only the first will pass the if (_inFlight) return check because Dart's event loop processes them sequentially within the same microtask.
Even Better: Extract It Into a Reusable Wrapper
/// Ensures only one execution at a time. Subsequent calls
/// while the action is in flight are silently dropped.
class SingleShotAction {
bool _inFlight = false;
bool get isRunning => _inFlight;
Future<T?> run<T>(Future<T> Function() action) async {
if (_inFlight) return null;
_inFlight = true;
try {
return await action();
} finally {
_inFlight = false;
}
}
}
// Usage:
final _orderAction = SingleShotAction();
Future<void> _placeOrder() async {
final result = await _orderAction.run(() async {
return await api.post('/orders', data: orderData);
});
if (result == null) return; // Was already in flight
// Handle result
}
// In the widget tree:
ElevatedButton(
onPressed: _orderAction.isRunning ? null : _placeOrder,
child: _orderAction.isRunning
? SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text('Place Order'),
)Solution 2: Request Deduplication for Data Fetching
For GET requests (loading data, search, refresh), you want to deduplicate: if a request for the same data is already in flight, join it instead of creating a new one.
class RequestDeduplicator {
final Map<String, Future> _inFlightRequests = {};
/// Returns the result of the request. If an identical request
/// is already in flight, returns its future instead of creating a new one.
Future<T> deduplicate<T>(
String key,
Future<T> Function() request,
) async {
if (_inFlightRequests.containsKey(key)) {
return await _inFlightRequests[key] as T;
}
final future = request();
_inFlightRequests[key] = future;
try {
final result = await future;
return result;
} finally {
_inFlightRequests.remove(key);
}
}
}
// Usage:
final _deduplicator = RequestDeduplicator();
Future<List<Product>> loadProducts({int page = 1}) {
return _deduplicator.deduplicate(
'products_page_$page',
() => api.get('/products?page=$page'),
);
}Now, if a user pulls to refresh while a load is already happening, both calls resolve to the same response. No duplicate requests.
Solution 3: Idempotency Keys (Server-Side Safety Net)
Client-side guards only protect against unintentional duplicates. For critical operations like payments or order placement, you need server-side idempotency.
The pattern: generate a unique key on the client, send it with the request, and have the server reject duplicate keys.
Client Side
import 'package:uuid/uuid.dart';
Future<void> placeOrder(OrderData order) async {
final idempotencyKey = const Uuid().v4(); // Generate once
final response = await api.post(
'/orders',
data: order.toJson(),
options: Options(
headers: {
'Idempotency-Key': idempotencyKey,
},
),
);
// Even if we retry this request with the same key,
// the server will return the original response
}Server Side (Node.js Example)
// Middleware that checks idempotency keys
async function checkIdempotency(req, res, next) {
const key = req.headers['idempotency-key'];
if (!key) return next();
// Check if this key was already processed
const cached = await redis.get(`idempotency:${key}`);
if (cached) {
// Return the cached response — don't process again
const { statusCode, body } = JSON.parse(cached);
return res.status(statusCode).json(body);
}
// Store the original response after processing
const originalJson = res.json.bind(res);
res.json = (body) => {
redis.setex(
`idempotency:${key}`,
86400, // Keep for 24 hours
JSON.stringify({ statusCode: res.statusCode, body })
);
return originalJson(body);
};
next();
}With this pattern, even if the client sends the same order request 5 times (network retries, user double-taps, app restarts), the order is only created once. Subsequent requests get the cached response.
Solution 4: Debounce for Search and Text Input
Debounce is specifically for rapid-fire events like typing. It waits until the user stops typing before firing the request:
class SearchScreen extends StatefulWidget {
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
Timer? _debounce;
final _deduplicator = RequestDeduplicator();
List<SearchResult> _results = [];
void _onSearchChanged(String query) {
// Cancel previous timer
_debounce?.cancel();
if (query.isEmpty) {
setState(() => _results = []);
return;
}
// Wait 300ms after last keystroke
_debounce = Timer(Duration(milliseconds: 300), () {
_performSearch(query);
});
}
Future<void> _performSearch(String query) async {
// Also deduplicate in case of rapid-fire calls
final results = await _deduplicator.deduplicate(
'search_$query',
() => api.get('/search?q=$query'),
);
if (mounted) {
setState(() => _results = results);
}
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
}Notice the two layers: debounce prevents firing on every keystroke, and deduplication prevents duplicate requests for the same query.
Which Solution to Use Where
| Scenario | Solution |
|----------|----------|
| Button taps (submit, save, send) | SingleShotAction guard |
| Pull-to-refresh / pagination | RequestDeduplicator |
| Search / text input | Debounce + deduplication |
| Payments / critical mutations | Idempotency keys (client + server) |
| Retry on network failure | Idempotency keys |
Key Takeaways
setStatedoesn't prevent race conditions. Use synchronous boolean guards.- Debounce is for text input, not buttons. Users expect instant response on tap.
- Deduplicate GET requests. Join in-flight requests instead of creating new ones.
- Use idempotency keys for mutations. It's the only way to prevent server-side duplicates.
- Layer your defenses. Client-side guards + server-side idempotency = bulletproof.
- Always show loading state immediately. If users can see their tap registered, they won't tap again.
I learned every one of these patterns the hard way — from real users creating real duplicate orders on real production apps. Save yourself the refunds and the support tickets. Build these guards from day one.
Find me at: itszain.dev