๐ฏ frame_scheduler
A production-grade, FPS-aware task scheduler for Flutter apps and games.
๐ง The Problem
Your Flutter app runs at 60 FPS in normal conditions. Then it needs to execute a heavy task โ parsing a large JSON response, pre-loading the next scene's assets, processing a list of 5,000 items. The FPS tanks to 15, the animation freezes, and the user feels the jank.
The problem isn't the work itself โ it's the timing.
๐ก The Solution
frame_scheduler hooks into Flutter's rendering engine via
SchedulerBinding.addTimingsCallback, monitors the real-time FPS continuously,
and automatically defers, prioritises, or drops tasks based on the current
performance health:
FPS is healthy? โ Execute now
FPS is degraded? โ Defer task to the priority queue
FPS recovered? โ Execute deferred tasks within the frame budget
FPS in danger? โ Drop non-critical tasks to relieve UI pressure
No polling loops. No manual FPS checks. No Timer.periodic hacks.
Just schedule โ the library decides when.
โจ Features
| Feature | Description |
|---|---|
| ๐ฌ Real-time FPS monitoring | Uses SchedulerBinding.addTimingsCallback โ the deepest, most accurate Flutter FPS source |
| ๐ 4-level priority system | Critical / High / Normal / Low with automatic deferral rules |
| โฑ Frame-budget awareness | Never schedules more work than fits in the available frame time |
| ๐ Smart deferred queue | Priority-sorted, bounded, deduplicated queue with expiry support |
| ๐ Built-in metrics | Executed / Deferred / Dropped / Expired counters with drop rate |
| ๐ง 4 presets + copyWith | balanced, performance, batterySaver, highRefresh |
| ๐ Auto priority escalation | Tasks close to expiry are automatically upgraded |
| ๐จ Debug overlay | FpsOverlay shows live FPS, zone, and queue depth |
| ๐ก Reactive widget | SchedulerBuilder rebuilds on zone changes |
| ๐ Zero dependencies | Pure Flutter + Dart, no third-party packages |
๐ฆ Installation
# pubspec.yaml
dependencies:
frame_scheduler: ^1.0.0
flutter pub get
๐ Quick Start โ 3 Steps
Step 1 โ Wrap your app
import 'package:frame_scheduler/frame_scheduler.dart';
void main() {
runApp(
FrameSchedulerScope(
config: SchedulerConfig.performance(),
child: const MyApp(),
),
);
}
Step 2 โ Schedule your tasks
final scheduler = SchedulerController.instance;
// Load assets for the next scene (deferrable)
scheduler.schedule(
() async => await assetLoader.preload('level_3'),
priority: PriorityLevel.high,
estimatedDurationMs: 80.0,
id: 'preload_level_3',
maxWaitMs: 5000,
onDropped: () => print('Preload dropped โ will use fallback'),
);
// Send analytics (safe to drop)
scheduler.schedule(
() async => await analytics.flush(),
priority: PriorityLevel.low,
estimatedDurationMs: 2.0,
maxWaitMs: 10000,
onDropped: () => print('Analytics dropped โ no FPS budget'),
);
// Critical โ always executes instantly
scheduler.schedule(
() async => soundEngine.play('coin'),
priority: PriorityLevel.critical,
);
Step 3 โ Add the debug overlay (development only)
Stack(
children: [
const MyGameScreen(),
if (kDebugMode) const FpsOverlay(showMetrics: true),
],
)
๐ FPS Zones
| Zone | Range (60fps target) | Emoji | Behaviour |
|---|---|---|---|
| Healthy | โฅ 48 FPS | ๐ข | All tasks execute immediately |
| Warning | 30โ48 FPS | ๐ก | Normal + Low priority tasks deferred |
| Critical | 15โ30 FPS | ๐ด | Only Critical + High execute; others drop |
| Danger | < 15 FPS | ๐ | Only Critical executes; queue is purged |
๐ Priority Behaviour Matrix
| Priority | ๐ข Healthy | ๐ก Warning | ๐ด Critical | ๐ Danger |
|---|---|---|---|---|
critical |
Execute | Execute | Execute | Execute |
high |
Execute | Execute | Defer | Drop |
normal |
Execute | Defer | Drop | Drop |
low |
Execute | Defer | Drop | Drop |
โ๏ธ Configuration Presets
SchedulerConfig.balanced() โ Default
// Best for: General apps, content browsing, social media
FrameSchedulerScope(config: SchedulerConfig.balanced())
| Parameter | Value |
|---|---|
| Target FPS | 60 |
| Warning threshold | 48 FPS (80%) |
| Critical threshold | 30 FPS (50%) |
| Window size | 60 frames |
| Max queue | 50 tasks |
SchedulerConfig.performance() โ Aggressive
// Best for: Heavy games, 3D apps, real-time simulations
FrameSchedulerScope(config: SchedulerConfig.performance())
| Parameter | Value |
|---|---|
| Target FPS | 60 |
| Warning threshold | 54 FPS (90%) โ defers sooner |
| Critical threshold | 40 FPS |
| Window size | 30 frames โ reacts faster |
| Max queue | 100 tasks |
SchedulerConfig.batterySaver() โ Low-end Devices
// Best for: Budget phones, older devices, background processing
FrameSchedulerScope(config: SchedulerConfig.batterySaver())
| Parameter | Value |
|---|---|
| Target FPS | 30 |
| Warning threshold | 24 FPS |
| Check interval | 500ms โ fewer wake-ups |
SchedulerConfig.highRefresh() โ 120Hz Displays
// Best for: iPad Pro, OnePlus, Samsung Galaxy S, Pixel 6+
FrameSchedulerScope(config: SchedulerConfig.highRefresh())
| Parameter | Value |
|---|---|
| Target FPS | 120 |
| Warning threshold | 96 FPS (80%) |
| Critical threshold | 60 FPS (50%) |
| Window size | 120 frames |
Custom Configuration
SchedulerConfig.balanced().copyWith(
maxDeferredTasks: 200,
deferCheckIntervalMs: 100,
safeBudgetRatio: 0.60,
autoAdjustPriority: true,
)
๐ Architecture
frame_scheduler/
โ
โโโ FpsMonitor โ Reads FrameTiming from SchedulerBinding
โ โโโ rolling window โ Smoothed FPS via N-frame average
โ
โโโ FrameBudget โ 16.67ms / 120fps budget calculator
โ โโโ computeZone() โ Maps FPS โ FpsZone (healthy/warning/critical/danger)
โ
โโโ TaskQueue โ Priority-sorted, bounded, deduplicated queue
โ โโโ PriorityLevel โ critical / high / normal / low
โ โโโ ScheduledTask โ Task + metadata (id, estimated duration, expiry)
โ
โโโ SchedulerController โ Singleton orchestrator โ THE main API
โ โโโ schedule() โ FPS-aware scheduling entry point
โ โโโ runCritical() โ Bypass-all emergency execution
โ โโโ metrics โ Cumulative SchedulerMetrics snapshot
โ
โโโ SchedulerConfig โ Immutable config object (4 presets + copyWith)
โ
โโโ Widgets
โโโ FrameSchedulerScope โ Lifecycle management widget
โโโ FpsOverlay โ Debug badge overlay
โโโ SchedulerBuilder โ Reactive zone-aware builder
โโโ ScheduleOnce โ One-shot schedule-on-mount widget
๐ Core API Reference
SchedulerController.instance.schedule()
Future<void> schedule(
Future<void> Function() task, {
PriorityLevel priority = PriorityLevel.normal, // Execution priority
double estimatedDurationMs = 5.0, // Budget hint (ms)
String? id, // Deduplication key
int? maxWaitMs, // Expiry timeout
void Function()? onDropped, // Drop callback
})
SchedulerController.instance.runCritical()
// Bypasses ALL FPS checks โ use only for genuine emergencies
Future<void> runCritical(Future<void> Function() task)
Read-only properties
SchedulerController.instance.currentFps; // double
SchedulerController.instance.currentZone; // FpsZone
SchedulerController.instance.pendingTaskCount; // int
SchedulerController.instance.metrics; // SchedulerMetrics
SchedulerController.instance.isRunning; // bool
๐ฎ Game Loop Integration Pattern
// In your game tick (called ~60 times/sec):
void _onGameTick() {
final scheduler = SchedulerController.instance;
// Physics: CRITICAL โ must never be skipped
scheduler.schedule(
() async => physicsEngine.integrate(dt),
priority: PriorityLevel.critical,
estimatedDurationMs: 2.0,
id: 'physics_${frame}',
);
// Enemy AI: HIGH โ important but can wait one cycle
if (frame % 10 == 0) {
scheduler.schedule(
() async => enemyAI.evaluate(gameState),
priority: PriorityLevel.high,
estimatedDurationMs: 6.0,
id: 'ai_${frame}',
maxWaitMs: 300,
);
}
// Asset streaming: NORMAL โ fine to defer
if (needsNewChunk) {
scheduler.schedule(
() async => worldStreamer.loadChunk(playerPosition),
priority: PriorityLevel.normal,
estimatedDurationMs: 35.0,
id: 'chunk_${chunkId}',
maxWaitMs: 8000,
);
}
// Telemetry: LOW โ safe to drop
scheduler.schedule(
() async => telemetry.send({'fps': scheduler.currentFps}),
priority: PriorityLevel.low,
estimatedDurationMs: 1.0,
maxWaitMs: 5000,
onDropped: () => telemetry.discard(),
);
}
๐ฌ How FPS is Measured
frame_scheduler uses SchedulerBinding.addTimingsCallback โ Flutter's
official, lowest-level frame timing API. Each callback delivers a batch of
FrameTiming objects, one per completed frame.
The FPS is computed using a rolling window average:
FPS = 1,000,000 ยตs รท mean(frame_durations_in_window)
Where each frame_duration is FrameTiming.totalSpan.inMicroseconds โ
the full wall-clock time including both build and raster phases.
A larger window (configurable via SchedulerConfig.fpsWindowSize) produces
smoother readings at the cost of slightly slower reaction to sudden drops.
๐งช Testing
# Run all tests
flutter test
# Run a specific test file
flutter test test/task_queue_test.dart
# Run benchmarks
dart run benchmark/scheduler_benchmark.dart
๐ Choosing the Right Priority โ Cheat Sheet
| Scenario | Priority |
|---|---|
| Responding to a user tap / button press | critical |
| Playing a game sound effect | critical |
| Updating a high-stakes UI element (health bar, score) | critical |
| Loading the next scene's required assets | high |
| Syncing game state to server | high |
| Showing a toast/snackbar | high |
| Pre-loading speculative assets | normal |
| Non-critical background animations | normal |
| Refreshing a feed in the background | normal |
| Sending analytics / telemetry | low |
| Background cache updates | low |
| Prefetching content the user might see | low |
๐ License
MIT License โ see LICENSE for details.
๐ค Contributing
Contributions are welcome! Please open an issue first to discuss what you'd like to change.
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Commit your changes:
git commit -m 'Add my feature' - Push to the branch:
git push origin feature/my-feature - Open a Pull Request
๐ Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- pub.flutter-io.cn: frame_scheduler
Libraries
- frame_scheduler
- frame_scheduler โ A production-grade, FPS-aware task scheduler for Flutter.