Image Caching in .NET MAUI 10: FFImageLoading Alternatives for Fast Loading (2026)
FFImageLoading was archived in 2024 and every .NET MAUI 10 team I have worked with has had to pick a replacement. This is the practical breakdown of the alternatives, with the benchmarks and gotchas I hit shipping three production apps.
Image caching in .NET MAUI 10 is the practice of storing decoded remote images in memory and on disk so they render instantly on the next paint, and after the FFImageLoading library was archived in 2024 the recommended path is the built-in Image control with UriImageSource.CachingEnabled, the Microsoft.Maui.Controls image pipeline, and a small helper library for advanced needs like WebP decoding, transformations, or lazy scrolling. I've shipped this in three apps in the last eighteen months (two feed-heavy consumer apps and one B2B tool with a photo timeline), and honestly, the short answer is this: the built-in caching is now good enough for most feed and profile screens, but you still need a strategy for placeholders, cache eviction, and format decoding.
FFImageLoading was archived by its maintainer in early 2024 and receives no further updates; community forks exist but do not target .NET MAUI 10 reliably.
.NET MAUI 10 ships built-in memory and disk caching via UriImageSource, controlled by CachingEnabled and CacheValidity, and it now honours HTTP Cache-Control headers on iOS and Android.
For WebP, AVIF, animated GIF, or SVG rendering, layer SkiaSharp or a community package on top of the built-in Image control rather than replacing the pipeline.
CollectionView scroll performance is dominated by decode time, not download time; cache decoded bitmaps in memory sized to the target view, not the source resolution.
Migrating a screen off CachedImage is usually a 20-line diff: swap the control, replace transformations, and remove the FFImageLoading Init calls in MauiProgram.cs.
Watch the Android memory ceiling: a 4032×3024 photo decoded at full resolution costs about 48 MB of managed heap on a Pixel 8.
What happened to FFImageLoading
FFImageLoading, maintained by Daniel Luberda, was the de-facto image caching library for Xamarin.Forms for years. It handled the things the framework did badly: disk cache with LRU eviction, decoded bitmap cache, SVG and WebP decoders, transformations, placeholder fallbacks, and a CachedImage control that dropped in wherever an Image would go. In early 2024 the maintainer archived the repository, citing the shift to .NET MAUI and the burden of solo maintenance. The FFImageLoading GitHub archive is still readable, and the NuGet packages still install, but the last release predates .NET MAUI 8, let alone 10.
Community forks appeared quickly. Two are worth naming: Drastic.FFImageLoading, which targeted MAUI 7 and 8 and remains reasonably usable, and a handful of internal forks that companies ship privately. None of them cleanly cover the .NET MAUI 10 handler pipeline changes, particularly the reworked Android ImageViewHandler that lands with .NET 10. If you are starting a new app in 2026 I would not pick FFImageLoading; if you are migrating an existing Xamarin.Forms app, keep it working during the port and cut it out one screen at a time. That is the sequencing I've used on every migration.
Built-in image caching in .NET MAUI 10
The Image control in .NET MAUI 10 supports memory and disk caching out of the box when the source is a URI. The two knobs are CachingEnabled (default true) and CacheValidity (default 24 hours). Setting the source in XAML or code creates a UriImageSource, and MAUI's handler downloads the bytes, decodes them on a background thread, stores the result on disk under the platform cache directory, and holds a decoded bitmap in a bounded LRU in memory.
avatarImage.Source = new UriImageSource
{
Uri = new Uri("https://cdn.example.com/avatar/42.webp"),
CachingEnabled = true,
CacheValidity = TimeSpan.FromDays(7)
};
Two behaviours here are new since Xamarin.Forms and often surprise people. First, MAUI 10 respects the HTTP Cache-Control: max-age and ETag response headers when it hits the network. If your CDN emits sensible headers you rarely need to tune CacheValidity manually. Second, the memory cache is keyed by the tuple (URI, target width, target height), so the same image requested at 80×80 and 200×200 stores two decoded bitmaps. That is usually what you want on a scroll list, but it means a poorly measured layout can silently double your image memory. This ties into the wider startup and memory tuning I covered in mastering .NET MAUI performance.
Comparing image caching options in 2026
So here are the four approaches I've shipped or seriously evaluated in the last year. None of them is universally right; pick based on the formats you need to render, whether you have transformations, and how disciplined your team is about layout sizing.
Feature
Built-in Image + UriImageSource
Drastic.FFImageLoading fork
SkiaSharp + custom cache
CommunityToolkit LazyView pattern
MAUI 10 support
First-class
Community, unofficial
Full, you own it
First-class
Disk cache
Built in, LRU
Built in, richer eviction
You write it (MonkeyCache is 30 lines)
Uses Image underneath
Memory cache
Bounded, per size
Configurable
Full control
Same as Image
WebP decode
Yes (Android), Yes iOS 14+
Yes
Yes, via SkiaSharp
Yes
AVIF decode
iOS 16+, Android 12+
Partial
Yes, via SkiaSharp
Platform-dependent
SVG
No natively
Yes
Yes, via Svg.Skia
No
Transformations
Basic (Aspect only)
Circle, blur, tint, etc.
Anything you code
Basic
Placeholder / error image
Manual (bind Source)
Built in
Manual
Slot-based, easy
Effort to adopt
Zero
Low (NuGet, Init call)
High
Low
My default for greenfield apps is the built-in Image plus a tiny ImageEx content view that adds a placeholder, error, and fade-in. I only reach for SkiaSharp when I need SVGs on cards or heavy transformations like blur-behind-hero. This aligns with the general principle from the .NET MAUI charting libraries comparison: prefer the platform pipeline until it stops paying for itself.
How do I migrate from FFImageLoading to .NET MAUI?
The mechanical migration is smaller than most teams expect. Most of the pain is in features you were using without realising you were using them (automatic downsampling is the usual culprit). Follow this order.
1. Remove FFImageLoading initialization
Delete CachedImageRenderer.Init() and any UseFFImageLoading() call from MauiProgram.cs. Remove the NuGet references. Rebuild and let the compiler point you at every ffimageloading:CachedImage usage.
ImageEx is not a framework control; it's a small wrapper you write once per app. It exposes a placeholder Image beneath a real Image, swaps them when the source loads, and applies a circular clip via a Border. I've written this control four times now and the code is essentially the same each time.
3. Replace transformations
Circular clipping is a Border with StrokeShape="Ellipse". Blur is a SkiaSharp SKCanvasView or a shader. Tinting is a Behavior that sets a ColorMatrixColorFilter via a handler. Do them one by one and delete the old ITransformation classes as you go.
4. Verify cache paths
Check FileSystem.CacheDirectory to confirm images are landing on disk. On iOS you should see files under Library/Caches; on Android under cache/. If you don't, either CachingEnabled is false or the response is uncacheable per HTTP headers.
Placeholders, error images, and transformations
The single feature people miss most from FFImageLoading is inline placeholders. Here is the minimal ImageEx I copy into every new MAUI app. It supports a placeholder, an error image, a fade animation, and circular clipping. Under 80 lines and no external dependencies.
public class ImageEx : Grid
{
public static readonly BindableProperty SourceProperty =
BindableProperty.Create(nameof(Source), typeof(ImageSource), typeof(ImageEx),
propertyChanged: OnSourceChanged);
public static readonly BindableProperty PlaceholderProperty =
BindableProperty.Create(nameof(Placeholder), typeof(ImageSource), typeof(ImageEx));
public static readonly BindableProperty ErrorImageProperty =
BindableProperty.Create(nameof(ErrorImage), typeof(ImageSource), typeof(ImageEx));
public static readonly BindableProperty IsCircularProperty =
BindableProperty.Create(nameof(IsCircular), typeof(bool), typeof(ImageEx), false,
propertyChanged: (b, _, __) => ((ImageEx)b).ApplyShape());
public ImageSource Source { get => (ImageSource)GetValue(SourceProperty); set => SetValue(SourceProperty, value); }
public ImageSource Placeholder { get => (ImageSource)GetValue(PlaceholderProperty); set => SetValue(PlaceholderProperty, value); }
public ImageSource ErrorImage { get => (ImageSource)GetValue(ErrorImageProperty); set => SetValue(ErrorImageProperty, value); }
public bool IsCircular { get => (bool)GetValue(IsCircularProperty); set => SetValue(IsCircularProperty, value); }
private readonly Image _real = new() { Aspect = Aspect.AspectFill, Opacity = 0 };
private readonly Image _placeholder = new() { Aspect = Aspect.AspectFill };
private readonly Border _border = new() { Padding = 0, StrokeThickness = 0 };
public ImageEx()
{
_border.Content = new Grid { Children = { _placeholder, _real } };
Children.Add(_border);
_real.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Image.IsLoading) && !_real.IsLoading && _real.Source != null)
_real.FadeTo(1, 200);
};
}
private static void OnSourceChanged(BindableObject bindable, object oldVal, object newVal)
{
var self = (ImageEx)bindable;
self._placeholder.Source = self.Placeholder;
self._real.Opacity = 0;
self._real.Source = newVal as ImageSource;
}
private void ApplyShape() =>
_border.StrokeShape = IsCircular ? new Ellipse() : new Rectangle();
}
You can extend this with an error handler by subscribing to the platform-specific OnImageLoadFailed via a handler mapper. In production I attach a tiny handler that swaps to ErrorImage when the download fails or the payload is not a decodable image.
WebP, AVIF, and SVG support
Format support is where the built-in pipeline still has holes. Here's the honest 2026 picture:
WebP: fully supported on Android since API 18 and on iOS since 14. Just serve .webp.
AVIF: Android 12+ decodes AVIF natively; iOS 16+ decodes it via ImageIO. Below those versions the image control returns the fallback image or fails silently. Serve JPEG or WebP as a fallback via <picture>-style negotiation on your CDN.
Animated GIF/WebP: set IsAnimationPlaying="True" on the Image. Works on both platforms in MAUI 10.
SVG: not supported natively by Image. Use Svg.Skia or the Aloha.SvgToImageSourceGenerator approach. For static icons prefer font glyphs or MAUI's FontImageSource.
The .NET runtime team tracks format work on the dotnet/maui GitHub issues tracker. The relevant thread for AVIF support and one for animated WebP on iOS were both open at the time of writing. If your product relies on either format, subscribe to those issues.
Disk cache and memory cache internals
Understanding what's actually stored on disk saves a lot of debugging time. In MAUI 10, disk cache entries are files under FileSystem.CacheDirectory/Microsoft.Maui.Controls.Cache/. The filename is a hash of the source URI. There is no metadata file, so the cache is stateless and self-healing: if you delete the directory the app will re-download on next launch.
The memory cache lives in a static ConcurrentDictionary<CacheKey, WeakReference<IPlatformImage>> on each platform's handler. On Android it holds a Bitmap; on iOS a UIImage. Because both are reference-counted native objects, GC pressure in managed code doesn't directly evict them. When memory is tight, Android calls onTrimMemory and MAUI clears the cache; on iOS the UIApplicationDidReceiveMemoryWarningNotification triggers the same. In practice I've seen this fire under real conditions maybe once a week on average phones with a large media app. If you have images larger than the screen, that number goes up dramatically because you're storing more than you need to.
CollectionView performance and image decode budget
A common myth is that image caches are about download time. On any modern phone, downloading a 20 KB WebP is fast; what takes time is decode. Decoding a 1200×1200 JPEG on Android to a display bitmap costs 4–8 ms on the UI thread if you're unlucky. Multiply by six visible cells and you drop frames.
The rules I use for smooth CollectionView scroll with remote images:
Ask your CDN for the exact size. Cloudinary, imgix, ImageKit, and Cloudflare Images all support size in the URL. Send ?w=160&dpr=2 for an 80×80 avatar on a 2x display.
Set fixed WidthRequest and HeightRequest on the image inside the cell. Variable heights force multi-pass measure.
Give the parent cell an explicit HeightRequest. Auto-height is the enemy of scrolling.
Use ItemsUpdatingScrollMode="KeepScrollOffset" when appending, or you will fight the scroll position while images load.
If you're seeing lag on old hardware, decode off the UI thread with a SkiaSharp helper and swap the bitmap in via Dispatcher.Dispatch. This is the escape hatch, not the default.
For deeper investigation into scroll jank, I documented the memory profiling workflow in find and fix memory leaks in .NET MAUI. The tools are the same for image-driven memory blow-ups.
Production pitfalls I hit
Three things burned me on the last three shipping cycles, in rough order of how often they hit. If you skim only one section of this piece, make it this one.
Cache never invalidates when the URL is the same but the image changed
This is by design. If your backend overwrites /avatar/42.webp on user upload, MAUI happily serves the stale disk copy for the next 24 hours. Fixes: append a version query string (?v=<etag>), send a proper Cache-Control: no-cache plus ETag, or set CacheValidity shorter than your worst-case update lag. I prefer the version query string because it works even if the CDN eats your cache headers.
iOS shows the wrong image in reused cells
When CollectionView recycles cells, the previous image lingers until the new one decodes. Set Source="{Binding AvatarUrl}" with an IValueConverter that returns a placeholder while the URL is empty, or set _real.Source = null in the setter before assigning the new one. Otherwise you get a visible flash.
Android memory ceiling with high-resolution photos
A user's camera roll photo is ~4000×3000. Displaying it in a 400×300 preview at that native resolution costs about 48 MB in memory. Either downsample using StreamImageSource and a SkiaSharp resize, or use the platform photo picker's low-quality mode. This is the same trap you hit with the .NET MAUI photo and media capture APIs.
Frequently Asked Questions
Is FFImageLoading still safe to use in a .NET MAUI 10 app in 2026?
It builds and runs, but it targets an older handler API and receives no fixes for issues that appear on newer Android or iOS versions. For a short-term migration it's fine; for a new app or a long-lived one, plan to move off within one release cycle.
Does .NET MAUI 10 support WebP and AVIF out of the box?
WebP: yes, on both platforms. AVIF: Android 12+ and iOS 16+. Below those versions the image fails to decode. Serve a JPEG or WebP fallback via content negotiation if you need to support older devices.
How do I clear the .NET MAUI image cache manually?
Delete FileSystem.CacheDirectory/Microsoft.Maui.Controls.Cache/ for the disk cache. There is no public API to clear the in-memory cache; the closest is triggering Application.Current.SendLowMemoryWarning() on iOS or waiting for GC. In practice I've never needed to clear memory manually.
Why is my remote image loading slowly on the first scroll but instantly after?
The first request goes to the network and disk; subsequent requests hit the memory cache. If the first scroll is still slow after images are cached, the bottleneck is decode time, not download. Fix the image dimensions and let the handler decode to the target size.
Can I preload images before they appear on screen?
Yes. Create a background UriImageSource and call await sourceHandler.LoadImageAsync(source, cancellationToken) on the platform handler. In practice I preload the next 10 items of a feed when the current visible index passes a threshold. The API surface is platform-specific but wrapping it in an IImagePreloader service takes about 50 lines.
Compare TestFlight, Google Play Internal Testing, and Firebase App Distribution for .NET MAUI 10 apps. Fastlane commands, a GitHub Actions matrix, and a per-layer channel picker for engineering, QA, and pre-launch beta.
A hands-on comparison of Mopups, The49.Maui.BottomSheet, and native sheet APIs for .NET MAUI 10 with runnable XAML, MVVM plumbing, detents, and the keyboard footguns I hit shipping each one in production.
A hands-on comparison of LaunchDarkly, ConfigCat, and Firebase Remote Config in .NET MAUI 10, with setup code, offline caching patterns, kill-switch design, and CI/CD wiring.