I Deleted the Button — Migrating Flutter AI Features from UI-Triggered to Hourly Cron Batch
Source: Dev.to
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%
- 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
Original Design & Problems
The initial design was straightforward:
- User presses “Generate AI Prediction”
- Supabase Edge Function is invoked
- OpenAI/Gemini API generates a prediction
- Result is displayed
After running this in production, three problems emerged.
1. Long latency & quota waste
- AI prediction takes 3–8 seconds. Users stared at a loading spinner—painful in a horse‑racing prediction app where users check results minutes before a race.
- Rapid button presses caused duplicate requests, wasting quota.
2. Cascading 429 errors
When OpenAI/Anthropic/Gemini quotas ran dry during peak hours, 429 errors cascaded across all AI‑dependent features:
ai-assistant: 400 → 429 → 429 → 429 (cascading failure)
There was no circuit‑breaker, so the entire AI assistant screen would crash.
3. Empty‑state abandonment
If users opened the prediction page before any AI had run, they saw an empty state. Analytics showed ~30 % of new users abandoned at this point.
Solution: Hourly Cron Batch & Circuit Breaker
Run predictions hourly
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
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.
UI simplification
- The “Run AI Prediction” button was removed.
- The brain icon in the AppBar was replaced with an informational tooltip and a race‑number badge.
// 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)),
),
Empty‑state message was updated:
- Before: “Tap the brain icon to generate AI predictions”
- After: “Predictions are auto‑generated by hourly batch (cron)”
I also added a race_number column to the horse_races table (migrated from the last two digits of race_id_ext), so each race card now shows a clear “1R / 2R / …” badge.
Circuit‑breaker for other AI features
Other AI features (e.g., ai-assistant) remain UI‑triggered, but now use a cool‑down guard to prevent cascading 429 errors.
// 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');
}
}
Usage in the AI call wrapper
Future 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;
}
}
The UI can read retryAfter to show a countdown timer instead of a generic error message.
Metrics
| Metric | Before | After |
|---|---|---|
| Prediction display latency | 3–8 seconds | 0 seconds (pre‑generated) |
| Duplicate API calls at peak | 40–60 req/hr | 0 (req. batch only) |
| Cascading 429 failures | 3–5 per month | 0 (circuit‑breaker) |
| Empty‑state abandonment | ~30 % | < 5 % |
Takeaways
-
Triggering AI from a button is a natural first implementation, but in production it incurs real costs:
- User wait time → move inference to batch, pre‑generate results
- Quota waste → schedule calls deliberately with cron
- Cascading failures → add a circuit‑breaker with a cooldown window
-
These patterns are not 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, making the app more resilient and user‑friendly.
Building in Public
Jibun Kabushiki Kaisha – Flutter Web + Supabase life‑management app
Tags: FlutterWeb Supabase buildinpublic AI CircuitBreaker GitHubActions