How chasing the right optimizations in the wrong place locked up a Flutter carousel that had plenty of hardware to spare.
I had done everything right.
Isolates for heavy processing, caching for repeated renders, viewport-aware lazy loading so off-screen photos were not burning cycles. This was textbook Flutter performance optimization, but the app still lagged, even on a flagship Android phone with 12 GB of RAM.
That is the thing about architectural mistakes. They do not care how clean your implementation is.
The App, and Where It Started Hurting
InstaFramer is an open-source bulk image editor for Instagram creators. The core use case is simple: take a batch of photos, fit them into Instagram's aspect ratios without cropping, and export them. To fill the empty space around the photo, users can pick a white background, a black background, or an extended blur background (the same photo, blurred and stretched to fill the frame).
The editor screen has a swipeable carousel. Select up to 20 photos, swipe through them, tweak the settings, and export.


Even with white or black backgrounds, the carousel was not silky smooth. Performance was passable on a flagship device, but the moment blur entered the picture, it became completely unusable. For each card, showing the real-time blur preview, in which the original photo is centered and scaled over a blurred version of itself, required a heavy and repeated process: decoding the image, resizing it, running a Gaussian blur, and compositing the result every time the settings changed.
I reached for the standard toolkit. Background isolates via compute() for the heavy lifting, a FutureBuilder in each PhotoCard to handle the async result, and Image.memory() to render the final Uint8List. Isolates are Dart's concurrency model; they run in separate memory spaces, so any data passed back to the main thread has to be serialized across that boundary.
On paper, this is correct Flutter. In practice, it was burning memory and CPU cycles far beyond what a background preview pipeline should ever need.
What "Correct" Looked Like
Here is the PhotoCard build method at this stage:
child: FutureBuilder<Uint8List?>(
future: _generatePreview(widget.photo, widget.settings),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return _buildLoadingState(); // spinner, "Processing preview..."
}
if (snapshot.hasError || !snapshot.hasData) {
return _buildErrorState();
}
return AspectRatio(
aspectRatio: widget.settings.aspectRatio.ratio,
child: Image.memory(snapshot.data!, fit: BoxFit.contain),
);
},
),And _generatePreview was calling imageProcessor.processPreview(photo, settings) , which ran in a background isolate, decoded the full image, downsampled it, ran the Gaussian blur, and returned a Uint8List back across the isolate boundary.
For one photo, this is fine. For 20 photos in a carousel, where each swipe could trigger a rebuild and changing the blur intensity slider could invalidate all cached previews simultaneously, this was a performance disaster in slow motion.
The App Froze While I Was Profiling the Freeze
I found out exactly how bad it was during a single debugging session.
At 10:54, I opened the CPU flame chart. The frames were taking close to a second to complete.

At 10:55, the performance overlay confirmed what I was seeing: 34 FPS average, with orange jank bars on nearly every frame.

At 10:57, while I was still looking at the data, this appeared:

The app had frozen while I was profiling the freeze.
Three photos: Not ten, not twenty. Three. And the app was already choking.
A Quick Note on Why Isolates Were Not the Problem
Before going further: isolates are not the villain here. They are the right tool for CPU-heavy work, and they genuinely helped during the export phase (more on that later).
The problem was specifically using isolates to generate UI previews: work that was happening on every render, on every card, in a live scrolling carousel. Every preview was a full round trip: decode on the isolate, Gaussian blur on the isolate, serialize the result into a Uint8List, ship it back across the isolate boundary, and hand it to the GPU to display.
The question I had not asked yet was simple: does this actually need to be a CPU operation at all?
I was not asking it yet. First, I tried to fix what I had.
The Fixes That Almost Worked
The obvious move after a freeze like that is to reduce scope. Stop processing everything at once. Be smarter about when and what to compute. The instincts were sound. The problem was that acting on them meant optimizing a pipeline that should not have existed in the first place.
False Fix 1: Viewport-Aware Lazy Loading
The first patch came from a straightforward diagnosis: the carousel was firing off preview generation for all 20 photos the moment the screen loaded. Even cards the user had not looked at yet were running _generatePreview, spinning up isolates, and allocating memory in parallel.
The fix was viewport gating. Only process photos that are actually visible or immediately adjacent. Every card beyond a distance of 1 from the current index gets a grey placeholder instead.
bool _isPhotoInViewport(int photoIndex, int currentIndex) {
final distance = (photoIndex - currentIndex).abs();
return distance <= 1;
}
child: isInViewport
? FutureBuilder<Uint8List?>(
future: _generatePreview(widget.photo, widget.settings),
...
)
: _buildViewportPlaceholder(...),This worked. Measurably. Instead of 20 concurrent decode-blur-serialize pipelines competing for memory, there were at most 3. The freeze window narrowed considerably. A 20-photo session that previously locked up immediately became usable for longer.
But the root cause was completely intact. Every time you swiped to a new card, the full pipeline kicked in:
Decode on the isolate โ Gaussian blur on the isolate โ Serialize the result โ Cross the isolate boundary โ Allocate a fresh Uint8List โ Hand it to the GPU
The viewport gate just reduced how many times it fired.
False Fix 2: LRU Cache + Cache-First Rendering
The second patch addressed a different inefficiency: cards that had already been processed were being reprocessed on every rebuild. The cache was the logical answer.
An LRU cache with maxCacheSize = 12 and priority-based eviction (current photo = 100, adjacent = 50, others = 10). The viewport logic moved from inside PhotoCard up to the carousel level (a cleaner separation that made the cache lookup straightforward). Before falling through to the FutureBuilder, check the cache first.
if (widget.previewCache.containsKey(cacheKey)) {
return AspectRatio(
aspectRatio: widget.settings.aspectRatio.ratio,
child: Image.memory(widget.previewCache[cacheKey]!, fit: BoxFit.contain),
);
}
// Cache miss , still falls through to FutureBuilder
return FutureBuilder<Uint8List?>(...);A cache hit was instant. No async, no spinner, no isolate. For photos the user had already visited, this felt genuinely snappy.
For everything else, the full round trip was still happening.
What Both Fixes Had in Common
Each fix was a layer of insulation around the real problem. Viewport gating reduced how many cards triggered the pipeline. The cache reduced how often any given card triggered it. Between the two, the freezes became less frequent and took longer to surface. The blast radius shrank. But neither one touched the underlying assumption: that generating a blurred preview was a CPU task.
Every cache miss, which means every first load of every card, was still doing the same thing: CPU decodes the image, CPU blurs it, CPU serializes the pixels into a Uint8List, that Uint8List crosses the isolate boundary, gets allocated on the main heap, and then gets handed to the GPU to draw.
The GPU was the final recipient of this data every single time. It was rendering the pixels the CPU had spent so much work preparing.
So: why was the CPU doing this at all?
The Question I Had Been Avoiding
Two commits in, the freeze required more photos to trigger. The jank required none.
The assumption underneath all of it: the preview the user sees should be pixel-identical to the exported output. Many image-editing workflows separate fast edit previews from the final processed output. But I had been treating that as someone else's product decision, not a performance insight I could borrow.
That assumption had survived two commits completely untouched. It was time to poke it.
The Test
I rewrote PhotoCard to use Stack + AssetEntityImage + BackdropFilter, ran an export on the same photo through the existing CPU pipeline, and pulled both outputs up side by side on my phone.
Then I zoomed in. Then I zoomed in more. I dragged both images around looking for any sign the GPU compositor had cut a corner somewhere. Color cast, edge softness, blur falloff, anything. Something had to be off. The CPU pipeline was doing actual Gaussian math. The widget tree was just compositing.
There was nothing there.
Same softness. Same falloff. Same color cast. The GPU compositor had been producing a visually identical result the entire time, as a byproduct of normal widget rendering. I had been bypassing it with a full decode-blur-serialize-transfer pipeline, paying the complete memory and compute cost, to produce the same output the widget tree was already giving me for free.
Flutter was already solving my problem. I just was not asking it to.
The Redesign
Here is PhotoCard after the pivot, now using AssetEntityImage from the photo_manager package instead of Image.memory.
class PhotoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: AspectRatio(
aspectRatio: settings.aspectRatio.ratio,
child: Stack(
fit: StackFit.expand,
children: [
_buildBackground(context),
Center(
child: Transform.scale(
scale: settings.scale,
child: AssetEntityImage(
photo,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(800),
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
),
),
],
),
),
);
}
Widget _buildBackground(BuildContext context) {
switch (settings.backgroundType) {
case BackgroundType.extendedBlur:
return Stack(
fit: StackFit.expand,
children: [
AssetEntityImage(
photo,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(150),
fit: BoxFit.cover,
),
BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: settings.blurIntensity * 0.5,
sigmaY: settings.blurIntensity * 0.5,
),
child: Container(color: Colors.black.withOpacity(0.1)),
),
],
);
case BackgroundType.white:
return Container(color: Colors.white);
case BackgroundType.black:
return Container(color: Colors.black);
}
}
}BackdropFilter applies the blur via the GPU compositor directly during the render pass, so there's no CPU pixel work involved. The background layer requests an intentionally small ThumbnailSize.square(150) thumbnail. Blur hides the resolution artifacts at this preview size, so 150 px is visually indistinguishable from full resolution once the shader is applied. The foreground (the layer the user actually looks at) gets ThumbnailSize.square(800). Higher fidelity where it matters, smaller where it does not.
blurIntensity * 0.5 as sigma was tuned to match the visual feel of the CPU-rendered export. The side-by-side test confirmed it.
There is no FutureBuilder. No spinner. No _generatePreview. No Uint8List. PhotoCard is now a StatelessWidget because there is genuinely no state left to manage.
The full architecture shift:
// BEFORE
Photo โ compute() isolate โ decode โ Gaussian blur
โ Uint8List โ isolate boundary โ Image.memory() โ GPU
// AFTER
AssetEntityImage (150px thumbnail) โ BackdropFilter (GPU shader) โ GPU compositorThe CPU no longer touches pixel data for previews.
With PhotoCard stateless, allowImplicitScrolling: true on the carousel became safe. Previously, implicit scrolling could wake up off-screen cards and trigger preview processing. After the pivot, the actual comment left in the carousel code reads:
// We don't need complex viewport logic anymore because
// PhotoCard is now just lightweight widgets.The viewport tracking was deleted. The LRU cache was deleted. The _previewCache parameter that had been threading through EditorScreen into PhotoCarousel into every PhotoCard was gone.
Net diff of this single commit: +304 lines added, -783 lines deleted.
The optimized solution is less code!
Export Is Where Isolates Actually Belong
The GPU pivot fixed previews. Export is a genuine CPU job: decode, blur, composite, encode, write to disk.
Previews needed to get off the CPU entirely. Export needs to use the CPU better.
Batching Instead of Queuing
The original export loop was sequential. One photo at a time, fully awaited, then the next:
for (int i = 0; i < photos.length; i++) {
final processedBytes = await _imageProcessor.processImage(asset, settings);
await tempFile.writeAsBytes(processedBytes);
await Gal.putImage(tempFile.path); // gal package โ saves to device gallery
yield i + 1;
}This is safe, but it is also slow. On a 10-photo batch, every idle CPU core means more time the user spends staring at a progress bar.
The fix was controlled concurrency. Process three photos at a time with Future.wait, yield progress after each batch completes, then start the next:
final batchSize = 3; // currently hardcoded at 3
for (var batchStart = 0; batchStart < photos.length; batchStart += batchSize) {
final batchEnd = (batchStart + batchSize).clamp(0, photos.length);
final batch = photos.sublist(batchStart, batchEnd);
final batchFutures = batch.asMap().entries.map((entry) async {
final asset = entry.value;
Uint8List? originBytes = await asset.originBytes;
if (originBytes == null) return null;
Uint8List? processedBytes = await _imageProcessor.processImage(
originBytes, settings, isExportProcessing: true,
);
if (preserveMetadata) {
processedBytes = _preserveMetadata(originBytes, processedBytes);
}
originBytes = null; // GC hint โ more on this below
final tempFile = File('${exportDir.path}/$exportFileName');
await tempFile.writeAsBytes(processedBytes);
await Gal.putImage(tempFile.path);
await tempFile.delete();
return globalIndex + 1;
});
final batchResults = await Future.wait(batchFutures);
for (final result in batchResults) { yield result; }
}Why three and not five or eight? Processing more full-resolution images concurrently means more multi-megabyte buffers alive simultaneously. Push it too far and the memory pressure starts to resemble the original preview freeze. Three was a conservative starting point rather than a benchmarked optimum, low enough to stay well clear of the memory pressure that caused the original freeze. It has held up across everything I tested, from a three-year-old midrange Pixel to a current flagship, and I have not had reason to push it higher.
Notice originBytes = null inside the batch closure. The export service loads the raw bytes, uses them for processing and metadata, then explicitly nulls the reference. This is a hint to the Dart garbage collector that the buffer is reclaimable. It does not guarantee immediate collection, but with three multi-megabyte buffers alive concurrently, narrowing the window each one stays pinned should reduce peak memory pressure.
Making Export Feel Instant
Even after batching, there was a UX problem. Tapping "Export" felt unresponsive. The screen would hang on the editor view for a beat before the processing screen appeared. I never fully isolated the cause. The symptom was obvious and the fix was straightforward.
Two things, back to back. First, emit a progress state at 0% before any work starts. This forces the UI to switch to the processing view immediately. Second, yield to the event loop before starting the export pipeline:
emit(PhotosProcessingState(
current: 0,
total: currentState.photos.length,
backgroundType: currentState.settings.backgroundType,
));
await Future.delayed(const Duration(milliseconds: 50));
await for (final progress in _exportService.exportPhotos(...)) {
emit(PhotosProcessingState(...));
}The 50 ms delay gives the framework time to render the 0% state before the export pipeline starts competing for resources. With it, the processing view renders first and the heavy work runs behind it. The difference between "nothing happened" and "it is already working" is 50 milliseconds of intentional patience.
One More Pass: BackdropFilter vs ImageFiltered
BackdropFilter got the preview off the CPU, but it was not the cheapest way to do it. BackdropFilter blurs whatever is already painted behind it, which means the engine has to save a layer and read back the framebuffer contents underneath. That machinery exists for effects like frosted glass over something dynamic behind it. In PhotoCard, the only thing being blurred is the single AssetEntityImage sitting directly below it in the Stack. Nothing dynamic behind it, so BackdropFilter's actual capability, blurring arbitrary content behind it, was never being used.
ImageFiltered applies the same blur directly to its own child's paint pass, no readback required. I swapped it in and profiled both under the same stress test: eight full-range drags on the blur intensity slider, 90 sampled frames each, profile mode on the emulator. The average raster time was close: 2.56 ms for BackdropFilter against 2.09 ms for ImageFiltered. The gap that mattered was in the worst frames. BackdropFilter's slowest frame during the drag hit 29.49 ms, past the 16.67 ms budget for 60 FPS. ImageFiltered's worst frame stayed at 6.38 ms. The saveLayer and framebuffer readback that BackdropFilter needs are not a steady tax. They show up as occasional spikes, and a slider drag is exactly the kind of interaction that exposes them.
case BackgroundType.extendedBlur:
return Stack(
fit: StackFit.expand,
children: [
// Nothing sits behind this layer, so filter the image itself
// rather than backdrop-filtering the stack.
ImageFiltered(
imageFilter: ui.ImageFilter.blur(
sigmaX: settings.blurIntensity * 0.5,
sigmaY: settings.blurIntensity * 0.5,
),
child: AssetEntityImage(
photo,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(150),
fit: BoxFit.cover,
),
),
Container(
color: Colors.black.withOpacity(0.1),
),
],
);The Actual Lesson
I had done everything right. That was the problem.
The isolates were correct. The cache was correct. The viewport gating was correct. Each one was a legitimate fix for a real inefficiency. Competently implemented, well-reasoned, and aimed squarely at the wrong thing.
None of them asked the one question that actually mattered: does this work belong on the CPU at all?
The GPU compositor handles blur as a byproduct of normal rendering. The thumbnail is already there. The shader runs in place. The CPU was running a full Gaussian pipeline to produce output the widget tree was already generating for free, one frame at a time, without being asked.
Before reaching for isolates, before sizing your cache, before writing a single viewport check, figure out which layer the work actually belongs to.
Flutter already had it handled. I just had to get out of the way.
Building Flutter Apps That Stay Fast Under Real Workloads
Flutter performance problems are not always solved by adding more caching, concurrency, or processing logic. As this experience shows, the larger gain can come from identifying which part of the rendering architecture should handle the work and removing unnecessary processing from the path. For teams working through similar performance and architecture challenges, explore our Flutter app development services, which include application development, performance optimization, integrations, maintenance, and quality assurance.







