Riverpod Infinite Rebuild Loop

SkillMonitoring & ops

Debug and fix infinite widget rebuild loops in Flutter apps using Riverpod state management. Use when: (1) Logs show "RAPID REBUILD" warnings or 50+ rebuilds in seconds, (2) UI becomes unresponsive or shows same content repeatedly, (3) Bug only affects slower devices or poor network conditions, (4) Provider watching creates circular dependencies with router/URL state. Covers ref.watch() overuse, watch+listener redundancy, and transitive dependency chains.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Riverpod Infinite Rebuild Loop skill

What this skill tells your AI

The instructions your AI receives, as published by divinevideo/divine-mobile in .agents/skills/riverpod-infinite-rebuild-loop/SKILL.md and read by ahel’s review.

Problem

Flutter widgets using Riverpod enter an infinite rebuild loop, causing:

  • 50+ widget rebuilds in seconds
  • UI becomes unresponsive
  • Same content rendered repeatedly
  • Bug manifests primarily on slower devices or poor network connections

Context / Trigger Conditions

Symptoms in logs:

⚠️ RAPID REBUILD #54! Only 15ms since last build
⚠️ RAPID REBUILD DETECTED! Only 6ms since last build

User-reported symptoms:

  • "Endless scroll loop" - same video/content keeps appearing
  • "App freezes" or becomes unresponsive
  • "Works on my phone but not on older devices"

Trigger scenarios:

  1. Multiple async providers completing at staggered times
  2. Provider that watches route/URL state AND updates URL in build
  3. Using ref.watch() AND adding manual listeners to same provider
  4. Transitive watches: Widget watches A, A watches B, Widget also watches B

Root Cause Analysis

Pattern 1: URL Update Feedback Loop

// BAD: Creates infinite loop
Widget build(BuildContext context) {
  final pageContext = ref.watch(pageContextProvider);  // Watches URL
  final videos = ref.watch(videosProvider);

  // Detect video moved position and "silently" update URL
  if (currentVideoIndex != urlIndex) {
    context.go('/home/$currentVideoIndex');  // URL change triggers rebuild!
  }
}

Loop: Videos reorder → URL updated → pageContextProvider emits → rebuild → videos may reorder again → repeat

Pattern 2: Watch + Listener Redundancy

// BAD: Double-subscribing causes double rebuilds
final cache = ref.watch(cacheProvider);  // Watch triggers rebuild
cache.addListener(onCacheChanged);        // Listener ALSO triggers action

Pattern 3: Transitive Watch Dependencies

// BAD: Double-watching same source
Widget build() {
  ref.watch(pageContextProvider);           // Watch #1
  ref.watch(derivedProvider);               // derivedProvider ALSO watches pageContextProvider!
}

Pattern 4: Staggered Async Provider Loading

// PROBLEMATIC on slow devices: Each watch triggers rebuild when provider completes
final a = ref.watch(asyncProviderA);  // Completes at T=100ms → rebuild
final b = ref.watch(asyncProviderB);  // Completes at T=200ms → rebuild
final c = ref.watch(asyncProviderC);  // Completes at T=350ms → rebuild
final d = ref.watch(asyncProviderD);  // Completes at T=500ms → rebuild
// On fast devices: all complete ~simultaneously, 1-2 rebuilds
// On slow devices: staggered completion, 4+ rebuilds

Solution

Step 1: Audit ref.watch() Usage

For each ref.watch() in build methods, ask:

  • Does this provider change frequently?
  • Do I need to REBUILD when it changes, or just REACT?
  • Am I also manually listening to this provider?

Riverpod Methods:

MethodBehavior
ref.watch()Subscribe + REBUILD on change
ref.read()Read once, NO rebuild
ref.listen()Subscribe + callback, NO rebuild

Step 2: Convert Unnecessary Watches

// BEFORE: Rebuilds on every change
final videoService = ref.watch(videoServiceProvider);

// AFTER: Read once, use listener for reactions
final videoService = ref.read(videoServiceProvider);
ref.listen(videoServiceProvider, (prev, next) {
  // React to changes without rebuilding
  if (next.hasNewVideos) refreshUI();
});

Step 3: Remove Watch + Listener Redundancy

// BEFORE: Double-subscription
final cache = ref.watch(cacheProvider);
cache.addListener(onCacheChanged);

// AFTER: Choose one approach
// Option A: Just watch (if rebuild is needed)
final cache = ref.watch(cacheProvider);

// Option B: Read + listen (if rebuild not needed)
final cache = ref.read(cacheProvider);
ref.listen(cacheProvider, (_, __) => onCacheChanged());

Step 4: Break URL Update Loops

// BEFORE: URL update in build causes loop
if (currentVideoIndex != urlIndex) {
  context.go('/home/$currentVideoIndex');
}

// AFTER: Don't update URL on content reorder
// Just track position with PageController, not URL
// OR use content ID in URL instead of index: /home/video/abc123

Step 5: Batch Initial Load

// BEFORE: Watch each async provider (N rebuilds on slow devices)
final a = ref.watch(asyncA);
final b = ref.watch(asyncB);

// AFTER: Create combined provider that waits for all
@riverpod
Future<CombinedState> combinedState(Ref ref) async {
  final a = await ref.watch(asyncA.future);
  final b = await ref.watch(asyncB.future);
  return CombinedState(a, b);
}
// Widget watches only the combined provider (1 rebuild)

Verification

After fixes:

  1. Run app on slow device or use network throttling
  2. Check logs for rebuild warnings - should see ≤5 rebuilds on startup
  3. Scroll/navigate and verify UI remains responsive
  4. No "RAPID REBUILD" warnings in logs

Example: Full Fix

Before (problematic):

class _HomeScreenState extends ConsumerState<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    final pageContext = ref.watch(pageContextProvider);
    final videos = ref.watch(videosProvider);  // Also watches pageContextProvider internally!

    // URL update loop
    if (videos.currentIndex != pageContext.index) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        context.go('/home/${videos.currentIndex}');
      });
    }

    return PageView(...);
  }
}

After (fixed):

class _HomeScreenState extends ConsumerState<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    // Only watch videos, read page context
    final pageContext = ref.read(pageContextProvider).requireValue;
    final videos = ref.watch(videosProvider);

    // Don't update URL on reorder - track with PageController only
    // URL only changes on explicit user navigation

    return PageView(...);
  }
}

Notes

  • This bug is timing-dependent - may not reproduce on fast devices
  • Test on physical devices with network throttling to catch issues
  • Add rebuild detection logging during development:
    static int _buildCount = 0;
    static DateTime? _lastBuild;
    
    @override
    Widget build(BuildContext context) {
      final now = DateTime.now();
      if (_lastBuild != null && now.difference(_lastBuild!).inMilliseconds < 100) {
        Log.warning('RAPID REBUILD #${++_buildCount}!');
      }
      _lastBuild = now;
      // ...
    }
    

References

Signals

GitHub stars
265
Forks
55
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
riverpod-infinite-rebuild-loop
Source
github.com/divinevideo/divine-mobile