A .NET service that's been running for a few days is often faster than the same service right after it started, without a single code change. That's not caching, and it's not load balancing. It's the .NET runtime rewriting the machine code underneath your application while it runs, based on how it's actually being used.
To see why, you have to look at what happens between writing C# and running it.
From C# to machine code
When you write something like:
Console.WriteLine("Hello World");
that's just C#. Nothing runs yet. When you run dotnet build, the Roslyn compiler turns it into Intermediate Language (IL). IL isn't machine code. It's a format every .NET runtime understands, packed into a .dll.
When you actually start the app with dotnet run, the CLR loads that assembly into memory. Still no machine code. Your methods haven't been compiled to anything the CPU can execute yet.
That happens the first time a method is called. The JIT compiler takes that method's IL and converts it to native machine code on the spot. Every call after that reuses the compiled version. It doesn't recompile from scratch each time. This is also why the first request after a deploy tends to be the slowest one: a bunch of methods are getting JIT compiled for the first time, right when a real user is waiting on them. Some teams send a few throwaway warm-up requests before putting a new instance behind the load balancer for exactly this reason.
The runtime keeps watching
Here's where it stops being a one-time compilation step. .NET doesn't just JIT a method once and move on. It watches how often that method actually gets called while the app is running.
If a method gets hit thousands or millions of times, the runtime decides it's worth spending more effort on. It recompiles that method again, this time with much more aggressive optimizations. That's Tiered Compilation: cheap, fast compilation up front so the app starts quickly, then heavier optimization applied only to the code that's actually hot.
Dynamic PGO (Profile Guided Optimization) takes this further. Instead of just noticing that a method is called a lot, the runtime watches the actual behavior inside that method during real production traffic.
Take this:
if (user.IsAdmin)
{
...
}
Say 99% of the users hitting this code are not admins. A traditional compiler has no way to know that in advance, so it generates code that treats both branches as equally likely. Dynamic PGO doesn't guess. It watches the real branch outcomes as the app runs, sees which path actually gets taken almost every time, and recompiles the machine code to favor that path.
That's the mechanism behind why a long-running ASP.NET Core service keeps getting faster over days or weeks: the runtime keeps accumulating data about its actual traffic and re-optimizing around it.
Where this doesn't apply
This whole pipeline (JIT, Tiered Compilation, Dynamic PGO) depends on the app running long enough to profile itself. That's a great fit for a backend API or service that stays up for days.
It's a bad fit for something that starts, does its job, and exits. For that, .NET offers Native AOT: everything is compiled to machine code ahead of time, before the app ever runs. You get very fast startup, lower memory use, and no JIT overhead at all. That makes it a solid choice for CLI tools, serverless functions, or small sidecar utilities: cases where startup time matters more than long-run optimization.
The tradeoff is that Native AOT doesn't play well with anything that leans on runtime reflection or dynamic code generation. Entity Framework Core, dynamic proxy libraries, ABP's interceptors, and similar frameworks either don't work under it or need real changes to work at all. I wouldn't reach for Native AOT as the default for a typical business API. It's built for a specific shape of workload, not a general replacement for the JIT.
A sluggish first request after a restart is expected. It's the JIT compiling methods for the first time, not a sign of a problem. And a service that feels noticeably faster after a few days under steady traffic than it did right after deploy is the runtime optimizing for the application you actually built, not the one a compiler guessed at during a build step.
Top comments (1)
One diagnostic refinement: tiered promotion and dynamic PGO are process-local, and they usually aren’t an open-ended days-long learning phase. If latency keeps improving for days, warmed EF models, serializer metadata, connection pools, DNS/TLS, or application caches may be contributing more than the JIT. I’d capture two windows from the same release—cold start through readiness, then steady state after representative traffic—and compare p95/p99 with System.Runtime counters plus a short trace for JIT and tiering events. Warm only safe, read-only paths; otherwise warm-up can hide a readiness or dependency bottleneck. Did your measured gain plateau after Tier 1 promotion, or continue as caches and pools filled?