ReadyToRun and Cold Starts: Why Is the First Request After a Deploy Slow?
Author: Oğuzhan KARAGÜZEL ·
You shipped a new release, opened the site, and the first page took more than a second. You refreshed and the same page arrived in 50 milliseconds. There is no bug in the code; this is the cold start cost of .NET applications. In this article I explain why the first request is slow, how much of that cost ReadyToRun removes, how to turn it on, and when it gains nothing at all, with my own measurements.
What happens on the first request?
When C# code is compiled, it isn't turned directly into machine code the processor can run, but into an intermediate language (IL). What turns IL into machine code is the JIT (Just-In-Time) compiler, which steps in while the application runs. The JIT compiles a method the moment it is first called. In a web application, the first request is the moment thousands of methods are called for the first time:
- The dependency injection container creates services for the first time.
- Routing, model binding and filters run for the first time.
- EF Core builds its model and translates the first query to SQL.
- Razor views run for the first time.
Each of these is new work for the JIT. On later requests these methods are already compiled, so the same page comes back much faster. Measured locally in Elevare, the platform this site runs on, the first page after a start took about 600 ms, and a warmed-up page about 50 ms.
.NET's own libraries have largely solved this problem already: the shared framework (Microsoft.NETCore.App, Microsoft.AspNetCore.App) ships precompiled. What the JIT really compiles is your code and the packages from NuGet; packages such as EF Core contain only IL.
What is ReadyToRun?
ReadyToRun (R2R) is a compilation format that, when you publish, adds machine code precompiled for that platform next to the IL. When the application runs, methods with R2R code don't need the JIT; the ready code is used directly. Because the IL stays in the file, the application is still an ordinary .NET application: it runs on the same runtime, with the same reflection capabilities.
That is what sets R2R apart from Native AOT, which "compiles everything ahead of time". R2R is a head start; the JIT is still there and steps in when needed. I cover the difference between the two in detail in JIT vs AOT.
How to turn it on
R2R is a publish setting, and it needs to know the target platform because the machine code it produces is platform-specific:
dotnet publish -c Release -r linux-x64 --self-contained false -p:PublishReadyToRun=trueOr in the project file:
<PropertyGroup>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>--self-contained false keeps the application using the .NET runtime on the server; that is the right choice for running on the official aspnet Docker image.
If you build images for both x64 and ARM servers in Docker, you have to pick the platform at build time. Elevare's Dockerfile does it like this:
ARG TARGETARCH
RUN RID=linux-$([ "$TARGETARCH" = "arm64" ] && echo arm64 || echo x64) \
&& dotnet restore WebMvc.csproj -r $RID -p:PublishReadyToRun=true
RUN RID=linux-$([ "$TARGETARCH" = "arm64" ] && echo arm64 || echo x64) \
&& dotnet publish WebMvc.csproj -c Release -o /app/publish --no-restore \
-r $RID --self-contained false -p:PublishReadyToRun=trueBuildKit fills TARGETARCH in automatically. The old "legacy builder" doesn't; in that case the script falls back to x64. The restore step needs the same -r and PublishReadyToRun values, because the R2R compiler (crossgen) is a package downloaded during restore.
I measured it: how much does it gain?
To see the effect, I built two sample applications and compiled each as JIT (a normal publish) and as ReadyToRun. What I measured is the time from starting the process to the first successful HTTP response. Each was started 15 times and the median taken. Environment: a Linux container on Docker Desktop (WSL2), limited to 2 cores and 2 GB of memory, .NET 10.
| Application | Build | First response | Memory | Publish size |
|---|---|---|---|---|
| Minimal API (two endpoints) | JIT | 195 ms | 62 MB | 152 KB |
| Minimal API (two endpoints) | ReadyToRun | 195 ms | 63 MB | 168 KB |
| MVC + Razor + EF Core (SQLite) | JIT | 1129 ms | 118 MB | 6.9 MB |
| MVC + Razor + EF Core (SQLite) | ReadyToRun | 659 ms | 118 MB | 16 MB |
The result has two parts:
- In a small minimal API, R2R gained nothing. The application's own code is a few lines; nearly all the code that runs comes from the ASP.NET framework, which is already precompiled. R2R has nothing to compile.
- In the application using MVC, Razor and EF Core, the first response got 42 percent faster (from 1129 ms to 659 ms). Here the controllers, the Razor view and, most importantly, IL-only packages such as EF Core were precompiled.
These measurements were taken on small samples on my own computer; the ratios will differ for your application. But the direction is clear: the more of your own code and IL packages your application contains, the more R2R gains.
The cost: size and peak performance
R2R isn't free in two ways:
- A larger publish. Every assembly holds IL and machine code side by side. In the sample, the publish grew from 6.9 MB to 16 MB. The Docker image and deploy time grow accordingly.
- R2R code isn't the best code. Precompiled code is produced without knowing how the application is used at run time. .NET balances this with tiered compilation: frequently called methods are, after a while, recompiled by the JIT, better, using information gathered at run time (dynamic PGO). So R2R speeds up startup without lowering the peak speed of a long-running application, because hot code gets recompiled anyway.
R2R code is also platform-specific: a publish produced for linux-x64 can't use its R2R code on an ARM server. That is why the image has to be built for the target platform.
When should you use it?
- Use it in applications with plenty of code and dependencies, using MVC, Razor Pages, Blazor Server or EF Core; especially on sites that deploy often, start new instances as they scale, or go cold frequently because traffic is low.
- No need in small services with a few endpoints; as the measurement shows, the gain is close to zero. If startup really is critical for such services, Native AOT is where to look.
- It isn't enough on its own: R2R reduces the JIT cost but doesn't remove the other work of the first request (building the EF Core model, the first database connection, empty caches).
In Elevare we don't rely on R2R alone. A "warm-up" service also runs, requesting the published pages itself in the background when the application starts and when the cache is cleared, so even the first visitor sees a warmed-up page. I describe that setup in tag-based eviction with ASP.NET Core Output Cache.
Conclusion
The slowness of the first request after a deploy is largely the compilation work the JIT does on first calls. ReadyToRun speeds up the first request by doing that work ahead of time, at publish; in my measurement the first response of an MVC and EF Core application got 42 percent shorter. The cost is a larger publish and a platform-specific build. In a minimal API with little code, it made no difference. To try it on your own application, a single -p:PublishReadyToRun=true and a measurement are enough. Another side of our deploy setup, the Docker leftovers that filled the disk, is covered in Why My Server Froze.

