Skip to main content
Back to all tutorials
Checked on Flutter 3.47.8 β€’ Dart 3.13.7
T01.03 β€’ Checked November 4, 2026: compiles with no errors and launches in a Flutter widget test
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 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