Skip to main content
Back to all tutorials
Tested: Flutter 3.24.3 • Dart 3.5.3
T01.03 Verified September 2026
Async Race Conditions Request IDs Cancellation mounted

Prevent Stale Asynchronous Results from Overwriting UI State

Master request identity sequencing, cancellation tokens, and mounted checks to prevent out-of-order network responses from corrupting newer screens.

1. The Core Challenge & Problem

When users perform rapid searches, switch tabs, or tap filter pills in quick succession, asynchronous HTTP responses can complete in a different order than they were dispatched. A slower response from the initial query ("fl") may resolve AFTER a faster response for the refined query ("flutter"). Without explicit request sequencing or cancellation, the older result silently overwrites the newer state, displaying incorrect data. Furthermore, calling setState() after a user has popped the route triggers "setState() called after dispose()".

2. Architectural Principles & Resolution

There are three complementary defenses against stale asynchronous mutations in Flutter: 1. **Monotonic Request ID (Sequence Token)**: Maintain an internal integer incremented on every new user action. Before updating state, compare the dispatch token with the active token: `if (requestId != _activeRequestId) return;`. If stale, discard the result immediately. 2. **Cancellation (CancelToken / StreamSubscription)**: If using HTTP clients like Dio or Stream controllers, cancel the previous in-flight request as soon as a new search is initiated. 3. **Stateful Lifecycle Guard**: Always verify `if (!mounted) return;` before calling `setState()`.

3. Complete Tested Flutter Code

main.dart (Flutter 3.x+ ready)
lib/main.dart Copy & Paste into a new Flutter project
import 'dart:async';
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: SearchRaceConditionDemo()));

class SearchRaceConditionDemo extends StatefulWidget {
  const SearchRaceConditionDemo({super.key});

  @override
  State<SearchRaceConditionDemo> createState() => _SearchRaceConditionDemoState();
}

class _SearchRaceConditionDemoState extends State<SearchRaceConditionDemo> {
  int _activeRequestId = 0;
  String _currentQuery = '';
  List<String> _results = [];
  bool _isLoading = false;

  // Simulated backend API with unpredictable artificial latency
  Future<List<String>> _mockApiSearch(String query, int simulatedLatencyMs) async {
    await Future.delayed(Duration(milliseconds: simulatedLatencyMs));
    return List.generate(3, (i) => 'Result ${i + 1} for "$query"');
  }

  void _onSearchChanged(String query) async {
    // 1. Increment monotonic request ID
    final int thisRequestId = ++_activeRequestId;
    setState(() {
      _currentQuery = query;
      _isLoading = true;
    });

    // Simulate edge case: First query takes 800ms, subsequent query takes only 200ms
    final latency = query == 'fl' ? 800 : 200;
    final data = await _mockApiSearch(query, latency);

    // 2. CRITICAL: Check both widget mounted status and monotonic request identity
    if (!mounted) return;
    if (thisRequestId != _activeRequestId) {
      // Stale response! Discard quietly without corrupting active UI state
      return;
    }

    setState(() {
      _results = data;
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('T01.03: Stale Async Prevention')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              onChanged: _onSearchChanged,
              decoration: const InputDecoration(
                labelText: 'Search (Type "fl" then quickly "flutter")',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            if (_isLoading) const LinearProgressIndicator(),
            const SizedBox(height: 8),
            Text('Active Request ID: $_activeRequestId', style: const TextStyle(fontWeight: FontWeight.bold)),
            const Divider(),
            Expanded(
              child: ListView.builder(
                itemCount: _results.length,
                itemBuilder: (context, i) => ListTile(title: Text(_results[i])),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

4. Expected Visual & Behavioral Result

Typing "fl" followed immediately by "flutter" ensures that when the delayed 800ms response for "fl" finally resolves, its request token is rejected as stale (_activeRequestId mismatch). The screen reliably displays the correct results for "flutter".

5. Common Pitfalls & Traps

Pitfall #1: Only checking if (mounted) without monotonic request tokens

Remedy: mounted only verifies if the widget is in the element tree; it does NOT protect against two concurrent requests where the older request arrives after the newer one.

Pitfall #2: Calling setState directly inside an unawaited Future callback

Remedy: Always capture local request IDs and check if (thisRequestId == _activeRequestId && mounted) before mutating UI state.

Pitfall #3: Relying solely on UI debouncing to fix race conditions

Remedy: Debounce reduces request frequency, but variable network transit delays can still cause response inversion. Both debouncing and request IDs are required for bulletproof network code.

Official Flutter References & Standards