ReadyToRun precompilation with crossgen2: startup benefits, size cost, version resilience, composite images and how it interacts with tiering.
ReadyToRun is ahead-of-time compilation that keeps the IL and adds precompiled native code to each assembly. Publish with <PublishReadyToRun>true</PublishReadyToRun> and a runtime identifier. At startup the runtime uses the native code instead of JIT-compiling, which cuts startup time; hot methods are still rejitted at Tier 1 so steady-state performance is kept. Costs: assemblies are larger (often two to three times) and R2R code is a little less optimised because it must stay valid across versions of dependencies outside its version bubble. Use it for services where cold start matters; it is still a JIT-based app, unlike NativeAOT.
ReadyToRun (R2R) is a publishing option that precompiles IL to native code ahead of time (using the crossgen2 compiler) and stores that code alongside the IL in the same assemblies. At start-up, the runtime uses the precompiled code instead of JIT-compiling those methods, so applications start faster. Hot methods are still re-JIT-ed later at Tier 1 for peak performance. It's a middle ground between pure JIT and NativeAOT.
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier> <!-- R2R code is platform-specific -->
</PropertyGroup>
dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true
Options: PublishReadyToRunComposite (compile app + framework into a composite image for further start-up gains, typically with self-contained), PublishReadyToRunEmitSymbols, partial R2R via PublishReadyToRunExclude.
The framework (shared runtime libraries) is already shipped as R2R; this option compiles your code and dependencies.
linux-x64, linux-arm64, win-x64).| JIT only | ReadyToRun | NativeAOT | |
|---|---|---|---|
| Start-up | Slowest | Faster | Fastest |
| Steady-state | Best (Tier 1 + PGO) | Same (re-JIT hot methods) | Good, no dynamic PGO |
| Size | Smallest | Larger | Self-contained native binary (often smaller than self-contained JIT apps when trimmed) |
| Compatibility | Full | Full | Restricted (no JIT, trimming, reflection limits) |
| Platform-specific | No | Yes | Yes |
Good fit:
Less valuable:
Measure: compare start-up time to first successful request, and JIT counts (dotnet-counters methods-jitted-count) with and without R2R.
An ASP.NET Core API on Azure Container Apps scaled from 2 to 20 replicas during morning peaks; new replicas took ~4 s to become ready and served slow first requests. Publishing with PublishReadyToRun=true (linux-x64) cut ready time to ~1.8 s and reduced first-request latency by about half; image size grew by ~40 MB, which was acceptable. NativeAOT wasn't an option because of EF Core features and a reflection-heavy reporting library, so R2R gave most of the benefit without compatibility risk.
The interviewer checks you know the startup options and their trade-offs.
Green flags: IL plus native code, needs RID, rejit at Tier 1, version bubble, size cost, comparison with NativeAOT, measured result.
Red flags: "R2R means no JIT"; claims it makes everything faster; cannot say when not to use it.
When a method is first called, the runtime checks whether the R2R image contains precompiled code for it and whether its fixups (references to types, methods, fields in other assemblies) can be resolved and are valid for the loaded versions. If so, it uses that code (no JIT). Otherwise it JITs. With tiering, R2R code counts as "tier 0-ish": hot methods get call counting and are recompiled at Tier 1.
R2R code must remain valid if dependencies are serviced (patched) independently. So crossgen2 avoids baking in assumptions like field offsets of types from other version bubbles, or inlining methods from other assemblies, using indirection cells resolved at run time. This makes R2R code somewhat slower than JIT Tier 1 code.
Composite images compile multiple assemblies (optionally including the framework) into a single native image with a shared version bubble, enabling cross-assembly inlining and reducing fixups — better start-up and code quality, but requires deploying exactly those versions together (self-contained).
You can use profile data (MIBC files from training runs, via dotnet-pgo) to compile only hot start-up methods, reducing size while keeping start-up gains ("partial R2R"). The .NET framework itself is compiled with static PGO profiles.
DOTNET_ReadyToRun=0 disables use of R2R code to compare.DOTNET_ReadyToRun_DisableR2R... style switches and ETW/EventPipe events (R2R get-entry-point events) show usage.dotnet-counters: lower JIT counts at start-up with R2R.PublishReadyToRun and measure time to first response.DOTNET_JitDisasmSummary=1 to see which methods are still JIT-ed with R2R.<!-- MyService.csproj -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
using System.Diagnostics;
var sw = Stopwatch.StartNew();
// Representative startup work: configuration, JSON, LINQ
var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = false };
string json = System.Text.Json.JsonSerializer.Serialize(
Enumerable.Range(1, 100).Select(i => new { Id = i, Name = $"Item {i}" }).ToList(), options);
Console.WriteLine($"Startup work done in {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"Methods JIT-compiled: {System.Runtime.JitInfo.GetCompiledMethodCount()}");
Console.WriteLine($"JIT time: {System.Runtime.JitInfo.GetCompilationTime().TotalMilliseconds:N0} ms");
dotnet publish -c Release -o out-r2r
DOTNET_ReadyToRun=0 ./out-r2r/MyService # same binary, R2R code ignored, for comparison
./out-r2r/MyService
What happens: enabling R2R to improve throughput. Why it is wrong: R2R mainly helps start-up; hot code is re-JIT-ed. Do instead: use it for cold start; profile steady state separately.
What happens: expecting portable R2R output. Why it is wrong: R2R code is platform-specific. Do instead: publish per target RID.
What happens: container images grow significantly. Why it is wrong: slower pulls and more storage. Do instead: measure trade-offs; consider partial R2R.
What happens: turning off tiered compilation. Why it is wrong: R2R code stays less optimised forever (or everything is fully JIT-ed at start-up). Do instead: keep tiering on.
What happens: expecting zero JIT at start-up. Why it is wrong: some methods and generics still JIT. Do instead: measure JIT counts.
What happens: using R2R for small, AOT-compatible services needing minimal cold start. Why it is wrong: NativeAOT gives bigger start-up and memory wins. Do instead: evaluate AOT compatibility first.
What happens: enabling R2R without baseline measurements. Why it is wrong: can't justify size/build costs. Do instead: measure time-to-ready and first-request latency.
What happens: servicing individual assemblies in a composite deployment. Why it is wrong: composite code assumes exact versions. Do instead: redeploy the whole composite set.
ReadyToRun is the low-risk startup lever: no code changes, full compatibility.
| Need | Choice |
|---|---|
| Faster cold start, keep all features | ReadyToRun |
| Even faster start, maximum framework inlining, self-contained | Composite R2R |
| Smallest, fastest start and you can accept restrictions | NativeAOT |
| Long-running service, no scaling pressure | Plain JIT, smaller images |
Seniors measure: time to first response, image size and steady-state throughput, and pick based on numbers rather than habit.
NativeAOT (<PublishAot>true</PublishAot>) compiles the whole app, with the parts of the runtime it needs, into one native executable with no JIT and no IL at run time. You get very fast startup, lower memory and a smaller deployment with no .NET runtime to install. The cost is a closed world: trimming is mandatory, Reflection.Emit and loading new assemblies at run time are not available, reflection works only on what the compiler can see, and libraries must be AOT-compatible. ASP.NET Core supports it for minimal APIs and gRPC but not MVC or Razor Pages. Fix the IL2xxx and IL3xxx warnings before shipping.
Tiered compilation (on by default since .NET Core 3.0) compiles a method first at Tier 0: fast to produce, barely optimised. The runtime counts calls; a method called about 30 times (after a short startup delay) is recompiled at Tier 1 with full optimisation on a background thread and the entry point is swapped. ReadyToRun code counts as a starting tier and is also promoted when hot. On-stack replacement (default on x64 and Arm64 since .NET 7) lets a long-running loop in a Tier 0 method jump into optimised code mid-execution. The result is fast startup and good steady-state performance, but benchmarks must warm up.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
How to tell it (STAR)
During [sales events] our checkout service scaled out, but new pods were slow to warm up. I showed JIT was most of the startup cost and enabled ReadyToRun. Time to first response dropped from [X] to [Y] and the image grew by [Z MB], which we accepted.
Last updated 30 Oct 2026.