When we started building Airo TV, the dedicated TV edition of the local-first Airo super-app, we ran into a problem that is easy to underestimate:
Large data processing and a 60 FPS TV interface do not mix well on low-memory hardware.
Airo TV needs to process real-world IPTV data such as:
- 100,000+ line M3U playlists
- Multi-megabyte XMLTV EPG guides
- Large metadata payloads
- Continuous network streams
On low-spec Android TV hardware, our initial implementation produced severe UI stuttering and, in some cases, Out-of-Memory crashes.
The problem wasn’t that Flutter couldn’t render the UI. The problem was that expensive data processing was competing with UI work on the execution path responsible for keeping navigation responsive.
- Keep the Flutter main isolate focused on UI and user input.
- Move CPU-intensive parsing to a background isolate and native Rust code.
- Use a TV-specific dependency graph instead of shipping unnecessary native binaries.
1. The Problem: A 60 FPS UI Competing with a Data Pipeline
A 60 FPS interface has roughly 16.7 ms per frame. If a synchronous operation takes 100 ms, it can consume roughly six frame intervals.
D-pad input · focus management · rendering
During large playlist parsing we observed frame-time spikes in the 100–300 ms range. D-pad navigation became noticeably less responsive while the parser was busy.
2. Before: Parsing the Playlist in Dart
Our initial implementation received the M3U content as a Dart String, split it into lines, searched each line and constructed channel objects.
Future<List<Channel>> parsePlaylist(String rawM3uData) async {
final lines = LineSplitter.split(rawM3uData).toList();
final List<Channel> channels = [];
for (int i = 0; i < lines.length; i++) {
if (lines[i].startsWith('#EXTINF:')) {
final nameMatch =
RegExp(r'tvg-name="([^"]*)"').firstMatch(lines[i]);
final groupMatch =
RegExp(r'group-title="([^"]*)"').firstMatch(lines[i]);
final url = lines[i + 1];
channels.add(
Channel(
name: nameMatch?.group(1) ?? 'Unknown',
group: groupMatch?.group(1),
url: url,
),
);
}
}
return channels;
}
There is nothing inherently wrong with this approach for a small playlist. The problem was scale.
A 100,000-channel playlist can contain tens of megabytes of text. Splitting the input into a large collection of strings, performing repeated searches and regular-expression operations, and creating thousands of application objects created substantial temporary allocation pressure.
3. The First Architectural Change: Separate UI from Data Processing
The first principle we adopted was simple:
The main isolate should not be responsible for expensive data processing when the UI needs to remain responsive.
We moved the parsing operation behind a background isolate.
D-pad · focus · rendering · UI state
coordinates the parsing operation
M3U / EPG parsing · byte scanning
Future<List<M3uEntry>> parsePlaylistOptimized(
Uint8List rawBytes,
) {
return Isolate.run(() {
return RustNativeApi.parseM3uBytes(
bytes: rawBytes,
);
});
}
The important architectural property isn’t simply “use Rust.” It is that the expensive parsing operation is no longer performed on the main UI isolate.
Rust can make the parser faster, but moving the work away from the UI execution path is what protects UI responsiveness.
4. Why Rust?
Once parsing was moved out of the UI isolate, we looked at the parser itself. The workload was largely CPU-bound text processing:
- finding line boundaries
- checking prefixes
- locating delimiters
- extracting fields
- constructing records
This is a good workload for native code. We could process the input as bytes and perform targeted scans instead of repeatedly transforming the entire payload into higher-level Dart strings.
use memchr::memchr;
pub struct M3uEntry {
pub name: String,
pub url: String,
pub group: Option<String>,
}
pub fn parse_m3u_bytes(bytes: &[u8]) -> Vec<M3uEntry> {
let mut entries = Vec::with_capacity(10_000);
let mut cursor = 0;
while let Some(line_end) = memchr(b'\n', &bytes[cursor..]) {
let line = &bytes[cursor..cursor + line_end];
if line.starts_with(b"#EXTINF:") {
entries.push(extract_entry_fast(line));
}
cursor += line_end + 1;
}
entries
}
M3uEntry values still contain owned Strings. The optimization is that the scanning stage works directly on byte slices and avoids unnecessary intermediate string transformations.
5. Why memchr Instead of Regex?
Regular expressions are useful, but they are not always the best tool for high-volume structured text parsing.
For a handful of records, the difference is usually irrelevant. At 100,000 records, unnecessary work gets multiplied.
#EXTINF:This gives the parser a predictable byte-oriented processing path and reduces the amount of intermediate string processing.
6. Flutter + Rust FFI
We use a native Rust core, airo_core, exposed to Flutter through our native FFI layer.
| Layer | Responsibility |
|---|---|
| Flutter | Rendering, focus management, navigation, user interaction and presentation state |
| Dart worker | Coordinates background parsing work and communicates results back to Flutter |
| Rust | Byte-level parsing, CPU-intensive processing and data transformation |
This separation gives each layer a clearer responsibility and makes performance bottlenecks easier to isolate.
7. The Second Problem: Native Binary Size
Moving parsing off the UI path solved one class of problems. We also found that our TV build was carrying native dependencies that were not appropriate for the target hardware.
The original application used a media stack based around libmpv. A simplified version of the dependency configuration looked like:
dependencies:
media_kit: ^1.1.0
media_kit_libs_android_video: ^1.1.0
For the TV application, this meant shipping native binaries that were significantly heavier than what we needed.
| Metric | Before |
|---|---|
| APK size | 85 MB |
| Idle RAM | 320 MiB |
| Peak playback RAM | 480 MiB |
On low-memory TV hardware, both package footprint and runtime memory are important constraints.
8. A Dedicated TV Build
Instead of treating every platform as if it had the same requirements, we created a dedicated TV configuration.
dependencies:
flutter:
sdk: flutter
video_player: ^2.8.0
The goal wasn’t simply to make the YAML file smaller. The goal was to make the dependency graph appropriate for the target device.
Cross-platform source code does not mean every platform should ship the same binary dependencies.
9. The Results
We measured the new pipeline on physical Chromecast with Google TV hardware running Android 12, using a 100,000-channel M3U playlist containing approximately 35 MB of text.
| Metric | Before | After | Change |
|---|---|---|---|
| Parsing time | 4,850 ms | 42 ms | ~115× faster |
| UI during parsing | 14 FPS | 60 FPS | Major improvement |
| Dart heap spike | 340 MiB | 18 MiB | ~94% lower |
| Peak total RAM | 480 MiB | 185 MiB | ~61% lower |
| APK package footprint | 85 MB | 28 MB | ~67% smaller |
| Cold app startup | 3.2 s | 0.7 s | ~4.6× faster |
The most important result wasn’t actually the 115× parsing improvement. It was the user experience.
D-pad input → CPU-heavy parsing → allocation pressure → frame-time spikes → sluggish navigation
D-pad input → Flutter UI, while background parsing runs separately → results return to Flutter state
10. What We Learned
1. Don’t put large CPU workloads on the UI execution path
There is no universal payload-size threshold that says when an operation must move to an isolate.
Instead, profile the workload. Look for long synchronous operations, repeated allocations, large temporary collections, frame-time spikes and garbage-collection activity.
If a parsing operation takes tens or hundreds of milliseconds, it should not compete with a 16.7 ms frame budget.
2. Use native code where the workload actually benefits from it
Rust wasn’t introduced simply because “Rust is faster.” We had a CPU-heavy, repetitive, byte-oriented workload operating on very large inputs. That is where a native parser made sense.
If your workload is mostly application logic or I/O waiting, an FFI layer may add complexity without providing meaningful benefits.
3. Optimize the dependency graph, not just application code
Memory usage isn’t only determined by Dart objects. Native libraries, media engines, codecs, graphics resources, caches and runtime components all contribute to the device’s memory budget.
A smaller package does not automatically mean lower runtime RAM, but removing unnecessary native components can reduce both package footprint and runtime overhead.
11. The Architecture We Ended Up With
D-pad · focus · TV UI · rendering
Isolate.run() · background work coordinationM3U parser · EPG parser · byte scanning
Separately, the TV application uses a leaner native dependency set:
Flutter + Dart + Rust Core + lightweight Android video stack + TV-specific dependencies
Rather than trying to make one giant application configuration work everywhere, we optimized the application around the actual constraints of the target device.
Final Takeaway
The biggest improvement didn’t come from one magical optimization.
Several problems were interacting:
We addressed those problems at the architectural level.
Don’t optimize only the function that is slow. Optimize where the work happens, what it allocates, and what the target device actually needs.
That approach took Airo TV from a UI that could become almost unusable during large playlist processing to one that remained responsive while the same workload was processed in the background.
Try Airo TV
Airo TV is open source and part of the modular Airo ecosystem.
- Product page: Airo TV
- Releases: Airo TV on APKPure
- Source code: Airo on GitHub
- Website: DevelopersCoffee
The TV build is designed for Android TV-class devices, including lower-spec hardware where memory usage and UI responsiveness are more constrained than on modern phones and desktops.