Start Debugging
2026-09-03 Updated 2026-09-03 migrationdotnet-11mauiandroid Edit on GitHub

Migrate a .NET MAUI Android app from Mono to CoreCLR in .NET 11

A step-by-step migration off Mono onto CoreCLR for .NET MAUI on Android: the API 24 floor, the Mono-only MSBuild properties that now break your build, why your APK grew, how to profile the startup regression with dotnet-dsrouter and dotnet-trace, and what a rollback actually looks like now that the Mono path is gone.

For a small app this migration is a TargetFramework bump, an android:minSdkVersion bump, and an afternoon of measuring. For a large one, budget a week, and expect the whole week to go into two things: deleting Mono-era MSBuild properties that now either do nothing or actively break the build, and chasing a startup regression that has nothing to do with your code. The payoff is real (unified diagnostics, tiered JIT, dynamic PGO, a plausible path to Native AOT on Android), but the honest framing is that this is not optional. As of .NET 11 Preview 6, Microsoft no longer surfaces a separate Mono path for Android, iOS, or Mac Catalyst. This guide targets .NET 11 Preview 7 (11.0.100-preview.7, released 2026-08-11) with .NET MAUI 11.0.0-preview.7, migrating from .NET 10 with Mono. .NET 11 GA is scheduled for 2026-11-10.

Why this is worth doing beyond “you have no choice”

What breaks

AreaChangeSeverity
Minimum Android APIRaised from 21 (Android 5.0) to 24 (Android 7.0)high
Android ABIsAndroid x86 (32-bit) is not supported under CoreCLRhigh
Mono AOT propertiesRunAOTCompilation, AndroidAotMode, UseInterpreter are Mono-only; RunAOTCompilation=true can still invoke MonoAOTCompiler and fail the buildhigh
Startup timeLarge apps have reported multi-second regressions and ANRshigh (situational)
APK sizeR2R images live inside your .dll files, so assemblies growmedium
NuGet packagesNU1703 when a package resolves MonoAndroid assets instead of net6.0-android or latermedium
Legacy resourcesXA0149 for legacy Xamarin.Android resources embedded in a dependencylow
Microsoft.Maui.Controls.CompatibilityPackage removed in Preview 6medium (only if referenced explicitly)
HTTP errorsAndroidMessageHandler transport failures throw HttpRequestException instead of WebExceptionlow
Runtime embeddingThe Android embedding APIs are not carried forward to CoreCLRhigh (if you use them)

The API level floor is the one that reaches your users. Per the breaking change notice, apps built with .NET 11 cannot be installed or run on API 21, 22, or 23. Check your Play Console distribution numbers before you start, because this is a decision about users, not a build setting.

Pre-flight checklist

Migration steps

  1. Capture the Mono baseline. On your current .NET 10 release build, install the APK and measure cold start with the Android activity manager, which reports TotalTime in milliseconds:

    # .NET 10, Mono, Release
    adb shell am force-stop com.example.myapp
    adb shell am start -W -n com.example.myapp/crc64...MainActivity

    Run it five times, discard the first, and record the median. Record the release APK or AAB size too. Verify: you have two numbers written down somewhere that is not your terminal scrollback.

  2. Move the target framework and the API floor together. Both changes, in one commit, because CoreCLR on Android requires API 24:

    <!-- .NET 11 Preview 7, MAUI 11.0.0-preview.7 -->
    <PropertyGroup>
      <TargetFrameworks>net11.0-android;net11.0-ios;net11.0-maccatalyst</TargetFrameworks>
      <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
    </PropertyGroup>

    If you set android:minSdkVersion by hand in Platforms/Android/AndroidManifest.xml, raise it to 24 so the manifest and the project agree. Verify: dotnet build -f net11.0-android -c Release succeeds and the generated manifest shows minSdkVersion="24".

  3. Delete or guard every Mono-only MSBuild property. Grep your .csproj, Directory.Build.props, and any CI-injected properties for RunAOTCompilation, AndroidAotMode, AndroidEnableProfiledAot, UseInterpreter, and UseMonoRuntime. RunAOTCompilation=true left in a Directory.Build.props is a known build break: the MonoAOTCompiler target still runs even though the app is on CoreCLR (dotnet/android#11068). Delete them outright, or if you still cross-build an older TFM, guard them:

    <PropertyGroup Condition="'$(UseMonoRuntime)' == 'true'">
      <RunAOTCompilation>true</RunAOTCompilation>
      <AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
    </PropertyGroup>

    Verify: dotnet build -f net11.0-android -c Release -bl and then search the binary log for MonoAOTCompiler. Zero hits is the pass condition.

  4. Clear the ABI list and the package warnings. Drop x86 from RuntimeIdentifiers if it is still there, since CoreCLR does not ship it:

    <RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>

    Then deal with NU1703. Introduced in Preview 5, it fires when a package resolves assets from the deprecated MonoAndroid folder: “Package ‘PackageName’ 1.0.0 uses the deprecated MonoAndroid framework instead of ‘net6.0-android’ or later.” Upgrade the package if a modern version exists. If it does not, you have found a Xamarin-era dependency that is now on borrowed time, and suppressing the warning is a decision to carry that risk, not a fix. Verify: dotnet restore is clean, or every remaining NU1703 is a package you have consciously triaged.

  5. Rebuild in Release and re-measure against step 1. Same device, same procedure, same number of runs:

    # .NET 11 Preview 7, CoreCLR, Release
    dotnet publish -f net11.0-android -c Release
    adb install -r bin/Release/net11.0-android/publish/com.example.myapp-Signed.apk
    adb shell am force-stop com.example.myapp
    adb shell am start -W -n com.example.myapp/crc64...MainActivity

    Microsoft’s own position is that Android lands “within 10 percent of Mono on startup and app size” for a baseline template app. Verify: if you are inside that band, you are done with the performance work. If you are 2x or worse, go to step 6 rather than start randomly toggling MSBuild properties.

  6. Profile the regression instead of guessing. Add an app.env file next to the .csproj containing DOTNET_DiagnosticPorts=127.0.0.1:9000,suspend, and reference it conditionally:

    <ItemGroup Condition="'$(AndroidEnableProfiler)'=='true'">
      <AndroidEnvironment Include="app.env" />
    </ItemGroup>

    Start the router, build with the profiler enabled, launch the app, then attach:

    dotnet-dsrouter server-server -ipcs ~/mylocalport -tcps 127.0.0.1:9000 --forward-port Android &
    dotnet build -f net11.0-android -c Release -t:Run /p:AndroidEnableProfiler=true
    dotnet-trace collect --diagnostic-port ~/mylocalport,connect

    Because the port was configured with suspend, the runtime blocks at startup until dotnet-trace connects, which is exactly what you need to see the startup path rather than everything after it. On Windows, use mylocalport instead of ~/mylocalport, since the IPC channel is a named pipe. Verify: you have a .nettrace file with a populated startup window, and you can name the top three methods by inclusive time.

  7. Tune only what the trace justifies. If assembly size is the problem, R2R is the first knob, because R2R images are packed inside the .dll files and that is why your assemblies grew:

    <PropertyGroup Condition="'$(Configuration)' == 'Release'">
      <PublishReadyToRun>false</PublishReadyToRun>  <!-- smaller APK, slower startup -->
      <TrimMode>full</TrimMode>                     <!-- default is partial -->
    </PropertyGroup>

    These pull in opposite directions: turning R2R off trades startup for size, and TrimMode=full buys size back but now trims your own code and your NuGet references, so it needs a full regression pass. Change one at a time and re-run step 5 between each. Verify: each knob is justified by a measured delta you can quote, not by a blog post.

  8. Roll out in stages. Ship to an internal track first and watch ANR rate specifically, not just crash rate. The reported CoreCLR failure mode on large apps is a startup that runs long enough for Android to kill the process, which shows up as ANRs rather than exceptions. Verify: ANR rate in Play Console after a week of internal testing is flat against your Mono build.

Verification checklist

Rollback plan

Say this part out loud: there is no runtime-level rollback anymore. <UseMonoRuntime>true</UseMonoRuntime> was documented as the escape hatch when CoreCLR became the default in Preview 4, and it was framed then as a temporary unblock while you filed a regression. Preview 6 removed the separate Mono path for Android, iOS, and Mac Catalyst. Treat the property as gone and do not build a release plan around it.

Your actual rollback is the target framework: keep the net10.0-android build green on a branch until the .NET 11 build has survived a real production rollout. That is a heavier rollback than flipping one property, which is precisely why steps 1 and 5 exist.

Gotchas that cost real time

The startup regression is real and it is not evenly distributed. Two issues document the failure mode: dotnet/android#10588 reports “an app that takes 1s to launch on mono can take 6s on coreclr,” with ANRs on Avalonia’s ControlCatalog.Android, and dotnet/android#10914 reports roughly 1.0s to 6.0s cold start and a 21 MB to 38 MB APK growth on 11.0.100-preview.2. Both are Avalonia rather than MAUI, and both predate the composite partial R2R and MIBC profile work that landed later in the preview cycle, so do not read them as your expected outcome. Read them as the reason step 1 is mandatory.

XAML-heavy startup paths are the ones that hurt. The common thread in the reports is reflection and XAML parsing during initialization, which is exactly the work partial R2R cannot precompile if the shipped .mibc profile does not cover your app’s shape. If your app builds a large visual tree before the first frame, that is where to look first.

UseInterpreter silently stops mattering. It was true by default in Debug on Mono, and it is what made Mono-era Hot Reload work. On CoreCLR it is inert. If you had it set for a reason (a dynamic code path that Mono AOT could not handle), that reason has not disappeared, it has just moved: CoreCLR on Android runs a real JIT in Debug, so the code will work, but re-test it deliberately rather than assuming.

Your APK contents change shape. Under Mono you shipped libmonosgen-2.0.so plus libaot-*.dll.so images. Under CoreCLR you ship libcoreclr.so, libclrjit.so, libmonodroid.so (the Android glue keeps its Mono-era name), and a single libassemblies.arm64-v8a.so holding compressed MSIL with R2R images. If you have build scripts, size budgets, or ProGuard/R8 configuration that name those files, they need updating.

Trimming is where the size actually is. MAUI still defaults to TrimMode=partial, which trims framework assemblies but leaves your code and your NuGet references alone. Most of the size complaints resolve into trimming complaints once you look at the per-assembly breakdown.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back