How I Prevented Duplicate API Requests on Slow Mobile Networks in Flutter
The Bug That Only Happens in the Field
Your app works perfectly in development.
Then a farmer in rural Nigeria opens it on a 2G connection. He fills in his farm details, taps Save, and nothing happens. So he taps again. And again.
Three requests reach your server. Three farm records get created. He has no idea why.
This is a weak network idempotency problem. The request went through. The response never came back. The app assumed failure.
A loading spinner and a disabled button won't save you here. The user closes the app, reopens it, and submits again. Or the OS kills the app mid-request. You need a smarter pattern.
This is how I solved it while building AgroXcel β a Flutter platform for Nigerian farmers managing farm data and boundaries on rural 2G/3G networks.
Why Not Just Disable the Submit Button?
Disabling a button only protects the current UI session.
It does not help when:
- The app is killed mid-request by the OS
- The user reopens the app and resubmits
- The network drops after the request has already left the device
- The server processes the request but the response never returns
Idempotency solves the problem at the request level, not the UI level. The button guard is still useful β but it is the last line of defence, not the first.
How the Pattern Works
User Taps "Save"
β
Generate UUID (once β never regenerated on retry)
β
Serialize payload β JSON string
β
Store in Hive queue
β
Send request + Idempotency-Key header
β
Success (2xx)?
ββββββββ΄βββββββ
Yes No (network error)
β β
Remove from queue Increment retryCount in Hive
Retry on next reconnect
(max 5 attempts)
The server uses the key to deduplicate. Same key = same intent = process only once.
The Setup
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.1
hive_flutter: ^1.1.0
uuid: ^4.3.3
dio: ^5.4.3
internet_connection_checker: ^1.0.0+1 # connectivity_plus alone is not enough
dev_dependencies:
hive_generator: ^2.0.1
build_runner: ^2.4.9
Step 1: Model the Pending Operation in Hive
Store payload as a JSON string, not Map<String, dynamic>. Hive's generated TypeAdapters do not reliably handle nested maps at runtime β serializing to a string avoids that entirely.
import 'dart:convert';
import 'package:hive/hive.dart';
part 'pending_operation.g.dart';
@HiveType(typeId: 0)
class PendingOperation extends HiveObject {
@HiveField(0)
late String id; // the idempotency key β generated once, never changed on retry
@HiveField(1)
late String endpoint; // e.g. '/api/v1/farms'
@HiveField(2)
late String payloadJson; // JSON string β NOT Map<String, dynamic> (Hive adapter limitation)
@HiveField(3)
late DateTime createdAt;
@HiveField(4)
late int retryCount;
// Convenience getter so callers don't have to decode manually
Map<String, dynamic> get payload => jsonDecode(payloadJson) as Map<String, dynamic>;
}
Run flutter pub run build_runner build to generate the adapter.
Step 2: The Offline Queue Service
class OfflineQueueService {
static const _boxName = 'pending_ops';
Future<Box<PendingOperation>> get _box async =>
await Hive.openBox<PendingOperation>(_boxName);
// Call this BEFORE making any network request.
Future<PendingOperation> enqueue(
String endpoint,
Map<String, dynamic> payload,
) async {
final op = PendingOperation()
..id = const Uuid().v4() // one UUID per user intent β never recreated
..endpoint = endpoint
..payloadJson = jsonEncode(payload) // serialize to string for Hive compatibility
..createdAt = DateTime.now()
..retryCount = 0;
final box = await _box;
await box.put(op.id, op); // key by idempotency key for direct lookup
return op;
}
Future<void> dequeue(String operationId) async {
final box = await _box;
await box.delete(operationId);
}
Future<List<PendingOperation>> getPending() async {
final box = await _box;
return box.values.toList();
}
}
Step 3: The Repository β Two Separate Methods for New vs Retry
This is the most important design decision in the whole pattern.
Do not call saveFarm() when retrying. That generates a new UUID and breaks the idempotency guarantee entirely. Use a dedicated retryOperation() method that reuses the original key.
class FarmRepository {
final Dio _dio;
final OfflineQueueService _queue;
FarmRepository(this._dio, this._queue);
// Called on first submission β generates the UUID and enqueues.
Future<void> saveFarm(Map<String, dynamic> farmData) async {
final op = await _queue.enqueue('/api/v1/farms', farmData);
await _sendOperation(op);
}
// Called by the retry sweep β reuses the ORIGINAL UUID. Never generates a new one.
Future<void> retryOperation(PendingOperation op) async {
await _sendOperation(op);
}
Future<void> _sendOperation(PendingOperation op) async {
try {
await _dio.post(
op.endpoint,
data: op.payload,
options: Options(headers: {
// Same key on every attempt β server deduplicates on this value.
'Idempotency-Key': op.id,
}),
);
// Dio throws on non-2xx by default, so reaching this line means
// the server returned 200, 201, 202, or 204 β any successful 2xx.
await _queue.dequeue(op.id);
} on DioException catch (e) {
if (e.response != null) {
// Got a real HTTP error (4xx/5xx) β not a network issue.
// Remove from queue: retrying a bad request won't help.
await _queue.dequeue(op.id);
rethrow;
}
// Network failure β leave in Hive and increment the retry counter.
op.retryCount++;
await op.save();
}
}
}
Step 4: The Riverpod Provider
final farmRepositoryProvider = Provider((ref) => FarmRepository(
ref.read(dioProvider),
ref.read(offlineQueueServiceProvider),
));
final saveFarmProvider =
StateNotifierProvider<SaveFarmNotifier, AsyncValue<void>>(
(ref) => SaveFarmNotifier(ref.read(farmRepositoryProvider)),
);
class SaveFarmNotifier extends StateNotifier<AsyncValue<void>> {
final FarmRepository _repo;
SaveFarmNotifier(this._repo) : super(const AsyncData(null));
Future<void> save(Map<String, dynamic> farmData) async {
// Guard against double-taps within the same session.
if (state is AsyncLoading) return;
state = const AsyncLoading();
state = await AsyncValue.guard(() => _repo.saveFarm(farmData));
}
}
In the UI:
ElevatedButton(
onPressed: state is AsyncLoading
? null
: () => ref.read(saveFarmProvider.notifier).save(farmData),
child: state is AsyncLoading
? const CircularProgressIndicator()
: const Text('Save Farm'),
)
Step 5: Retry on Reconnection β With a Real Internet Check and a Cap
connectivity_plus and Connectivity() tell you whether the device is connected to a network β WiFi, mobile data, ethernet. They do not tell you whether the internet is actually reachable. A device on WiFi with no uplink passes the connectivity check and still fails the request.
Use internet_connection_checker for a real probe:
class ConnectivityRetryService {
final OfflineQueueService _queue;
final FarmRepository _repo;
ConnectivityRetryService(this._queue, this._repo);
void listen() {
Connectivity().onConnectivityChanged.listen((result) async {
if (result == ConnectivityResult.none) return;
// Connectivity β internet. Probe a real host before retrying.
final hasInternet = await InternetConnectionChecker().hasConnection;
if (!hasInternet) return;
final pending = await _queue.getPending();
for (final op in pending) {
// Hard cap β after 5 failures, surface an error instead of retrying forever.
if (op.retryCount >= 5) {
// TODO: emit a notification or a Riverpod state so the UI can prompt the user.
continue;
}
// Use retryOperation β NOT saveFarm β to preserve the original UUID.
await _repo.retryOperation(op);
}
});
}
}
What the Server Needs to Do
Store the idempotency key and return the existing result on duplicate:
// Spring Boot β same concept applies in NestJS
@PostMapping("/api/v1/farms")
public ResponseEntity<?> createFarm(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody FarmRequest request
) {
// Return the already-created record instead of inserting a duplicate.
if (farmRepo.existsByIdempotencyKey(idempotencyKey)) {
return ResponseEntity.ok(farmRepo.findByIdempotencyKey(idempotencyKey));
}
return ResponseEntity.status(201).body(farmService.create(request, idempotencyKey));
}
Add a UNIQUE index on idempotency_key in your migration. The database is your last line of defence if the application-level check ever races.
In production, expire idempotency keys after a reasonable retention window (24β72 hours is common) to prevent unbounded table growth. A nightly cleanup job or a TTL-based index on created_at handles this cleanly.
Results
After implementing this pattern in AgroXcel:
- Duplicate submissions were eliminated during testing on unstable 2G/3G networks
- Failed submissions could be recovered automatically after reconnection without user action
- Users no longer needed to re-enter data after temporary network failures
The Hive queue also gave us a side benefit: farm data is never silently lost mid-submission, even if the user gets a call, switches apps, or the OS kills the process.
Quick Checklist
- [ ] Generate the idempotency key once per user intent β not per retry
- [ ] Store
payloadas a JSON string in Hive β notMap<String, dynamic> - [ ] Write to Hive before the network call, never after
- [ ] Separate
saveFarm()(new key) fromretryOperation()(reuse key) - [ ] Dequeue on any 2xx response β Dio throws on anything else by default
- [ ] Use
InternetConnectionCheckerin the retry sweep, not justConnectivity - [ ] Implement a retry cap (5 attempts) and surface failures to the user when hit
- [ ] The server stores the key with a
UNIQUEconstraint and returns the existing result on duplicate
Built while developing AgroXcel β a Flutter platform for Nigerian farmers managing farm boundaries and records on rural 2G/3G networks.













