DEV Community

kanta13jp1
kanta13jp1

Posted on

I Deleted the Button — Migrating Flutter AI Features from UI-Triggered to Hourly Cron Batch

I Deleted the Button — Migrating Flutter AI Features from UI-Triggered to Hourly Cron Batch

Introduction

I removed the "Run AI Prediction" button from my app. That sounds like a step backward — but it was actually one of the best architectural decisions I made this week.

By moving AI inference from UI-triggered calls to an automated hourly cron job (GitHub Actions), I eliminated all user wait times, cut API quota waste by ~90%, and added a circuit breaker to prevent cascading 429 failures across the rest of the app.

This post covers the what, why, and how — using my personal project "Jibun Kabushiki Kaisha" (Self Corporation), a Flutter Web + Supabase life management app, as the case study.

Relevant if you:

  • Build Flutter apps with Supabase Edge Functions + AI APIs
  • Have dealt with rate limiting (429 errors) from OpenAI, Anthropic, or Gemini
  • Want to reduce perceived latency in AI-powered features

The Problem: 3 Pitfalls of UI-Triggered AI

The initial design was straightforward: user presses "Generate AI Prediction" → Supabase Edge Function is invoked → OpenAI/Gemini API generates prediction → result displayed.

After running this in production, three problems emerged.

1. User Waiting Time (3–8 seconds)

AI prediction takes 3 to 8 seconds. Users had to stare at a loading spinner — especially painful in a horse racing prediction app where users check results in the minutes before a race. Multiple rapid button presses caused duplicate requests and wasted quota.

2. Cascading 429 Failures

When OpenAI/Anthropic/Gemini quotas ran dry during peak hours, 429 errors would cascade across all AI-dependent features in the app:

[Browser console]
ai-assistant: 400 → 429 → 429 → 429 (cascading failure)
Enter fullscreen mode Exit fullscreen mode

There was no circuit breaker, so the entire AI assistant screen would crash.

3. "Empty State" Abandonment

When users opened the prediction page before any AI had run, they saw an empty state. Analytics confirmed ~30% of new users abandoned here.


Solution 1: Hourly Cron Batch with GitHub Actions

The fix for problems 1 and 3 was simple: run AI predictions automatically every hour, so predictions are always ready when users open the app.

# .github/workflows/horse-racing-update.yml
on:
  schedule:
    - cron: '0 * * * *'  # Every hour at :00
Enter fullscreen mode Exit fullscreen mode

The workflow runs fetch_horse_racing.py, generates AI predictions, and UPSERTs results into the Supabase horse_races table. When users open the app, data is already there — zero wait time.

The Flutter side became simpler too. The "Run AI Prediction" button was removed entirely. The "brain" icon in the AppBar was replaced with an informational tooltip:

// Before
ElevatedButton(
  onPressed: _runAiPredictions,  // DELETED
  child: const Text('Run AI Prediction Now'),
),

// After — race number badge (no button needed)
Container(
  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  decoration: BoxDecoration(
    color: AppTheme.orange,
    borderRadius: BorderRadius.circular(12),
  ),
  child: Text('${race.raceNumber}R',
    style: const TextStyle(color: Colors.white, fontSize: 11)),
),
Enter fullscreen mode Exit fullscreen mode

Empty state message updated:

Before: "Tap the brain icon to generate AI predictions"
After:  "Predictions are auto-generated by hourly batch (cron)"
Enter fullscreen mode Exit fullscreen mode

I also added a race_number column to the horse_races table (migrated from the last 2 digits of race_id_ext), so each race card now shows a clear "1R / 2R / …" badge.


Solution 2: AiQuotaGuard Circuit Breaker

Even with batch processing for horse racing, other AI features (like ai-assistant) are still UI-triggered. The circuit breaker pattern prevents 429 errors from cascading.

// lib/services/ai_service.dart

class AiQuotaGuard {
  static DateTime? _cooldownUntil;
  static const _cooldownDuration = Duration(seconds: 60);

  static bool get isInCooldown {
    if (_cooldownUntil == null) return false;
    return DateTime.now().isBefore(_cooldownUntil!);
  }

  static Duration get remainingCooldown {
    if (_cooldownUntil == null) return Duration.zero;
    final r = _cooldownUntil!.difference(DateTime.now());
    return r.isNegative ? Duration.zero : r;
  }

  static void triggerCooldown() =>
      _cooldownUntil = DateTime.now().add(_cooldownDuration);

  static bool isQuotaError(dynamic error) {
    final msg = error.toString().toLowerCase();
    return msg.contains('429') ||
        msg.contains('quota') ||
        msg.contains('rate limit') ||
        msg.contains('too many requests');
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in the AI call wrapper:

Future<String> callAiAssistant(String prompt) async {
  if (AiQuotaGuard.isInCooldown) {
    final secs = AiQuotaGuard.remainingCooldown.inSeconds;
    throw AIServiceException(
      errorType: 'RATE_LIMIT',
      message: 'AI service is temporarily limited. Retry in ${secs}s.',
      retryAfter: secs,
    );
  }
  try {
    return await _invokeFunction('ai-assistant', {'prompt': prompt});
  } catch (e) {
    if (AiQuotaGuard.isQuotaError(e)) {
      AiQuotaGuard.triggerCooldown();
      rethrow;
    }
    rethrow;
  }
}
Enter fullscreen mode Exit fullscreen mode

The UI can read retryAfter to show a countdown timer instead of a generic error message.


Results

Metric Before After
Prediction display latency 3–8 seconds 0 seconds (pre-generated)
Duplicate API calls at peak 40–60 req/hr 0 (batch only)
Cascading 429 failures 3–5 per month 0 (circuit breaker)
Empty-state abandonment ~30% < 5%

Conclusion

"Triggering AI from a button" is a natural first implementation, but in production it has real costs:

  • Users wait → move inference to batch; pre-generate results
  • Quota gets wasted → use cron; plan API calls deliberately
  • Cascading failures → add a circuit breaker with a cooldown window

None of these patterns are Flutter-specific. They apply to any app that integrates AI APIs.

Removing the button wasn't a feature deletion. It was a shift of responsibility from the UI layer to the automation layer — and the app is better for it.


Building in public: Jibun Kabushiki Kaisha

FlutterWeb #Supabase #buildinpublic #AI #CircuitBreaker #GitHubActions

Top comments (0)