How We Fixed 60 FPS TV UI Jank and OOM Crashes with Flutter and Rust

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.

Our solution had three parts:

  1. Keep the Flutter main isolate focused on UI and user input.
  2. Move CPU-intensive parsing to a background isolate and native Rust code.
  3. 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.

Main Dart Isolate
D-pad input · focus management · rendering
M3U string splitting · regex parsing · object creation
Temporary allocations · GC pressure
Frame-time spikes → visible UI jank

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.

Main Dart Isolate
D-pad · focus · rendering · UI state
↓ background work
Background Dart Isolate
coordinates the parsing operation
↓ FFI
Rust Core
M3U / EPG parsing · byte scanning
Parsed results
Flutter UI state
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
}
A precision point: we don’t describe the complete pipeline as “zero-copy.” The final 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.

Raw bytes
Find newline boundaries
Check #EXTINF:
Locate attribute boundaries
Extract required fields → M3uEntry

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.

Before

D-pad input → CPU-heavy parsing → allocation pressure → frame-time spikes → sluggish navigation

After

D-pad input → Flutter UI, while background parsing runs separately → results return to Flutter state

Benchmark note: These are our observed measurements for this workload and device. They should be treated as application-specific results, not universal Flutter-vs-Rust performance numbers.

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

Flutter UI
D-pad · focus · TV UI · rendering
Dart Worker
Isolate.run() · background work coordination
↓ FFI
Rust Core
M3U parser · EPG parser · byte scanning
Parsed results
Flutter state → UI

Separately, the TV application uses a leaner native dependency set:

Airo TV
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:

Large inputs + excessive allocations + CPU-heavy parsing + UI work + low-memory hardware + unnecessary native binaries

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

Leave a Reply

Your email address will not be published. Required fields are marked *