How C# Code Executes: IL, JIT, ReadyToRun and Native AOT
Welcome to Production .NET — where "it works" isn't enough anymore. Now you need to know why it works.
You've written hundreds of methods, launched dozens of apps, and typed dotnet run more times than you can count. Every time, the same thing happens: your C# turns into a running program. But have you ever stopped to ask what actually happens in the half-second between hitting Enter and seeing output on screen?
It's not magic, and it's not a single step. Your C# source code goes through two separate compilations — one you control (at build time) and one you don't (at run time) — before a single CPU instruction executes. Understanding this pipeline is the foundation for everything else in this Advanced tier: garbage collection, JIT tuning, memory layout, and performance diagnostics all sit on top of it.
In this lesson, you'll trace the complete journey from the C# you type to the machine code your CPU actually runs — and meet the two compilers, the runtime, and the alternatives to the default pipeline that modern .NET offers.
What Is It?
The Simple Explanation
Running a C# program is a two-stage translation, not one:
- Stage 1 (build time): The Roslyn compiler turns your
.csfiles into IL (Intermediate Language) — a CPU-independent instruction set — and packages it into an assembly (a.dllor.exe) along with metadata describing every type, method, and field. - Stage 2 (run time): When you actually run the program, the CLR loads that assembly and its JIT compiler translates the IL into real, CPU-specific machine code — the only thing a processor can actually execute.
You already met this pipeline at a high level back in Foundations. This lesson goes underneath it — the actual mechanics of tiered JIT compilation, and the alternatives (ReadyToRun, NativeAOT) that reshape when that second translation happens.
The Technical Definition
Roslyn is the open-source C# (and Visual Basic) compiler platform. It performs lexing, parsing, semantic analysis, and code generation, emitting IL plus metadata into a Portable Executable (PE) file — the assembly.
The CLR (Common Language Runtime) is the execution engine that loads assemblies, verifies them, and hosts the JIT (Just-In-Time) compiler, which compiles IL to native machine code method by method, on demand, the first time each method actually runs. Modern .NET uses tiered compilation: a fast, lightly-optimized pass (Tier 0) gets code running quickly, and a slower, heavily-optimized recompilation (Tier 1) kicks in later for methods that turn out to be "hot" (called frequently).
Compile Time (Roslyn)
- Runs once, on your machine or build server
- C# source → IL + metadata
- Catches syntax and type errors
- Output is CPU-independent — the same DLL runs on x64, Arm64, Windows, or Linux
Run Time (CLR / JIT)
- Runs every time the app starts, on the end user's machine
- IL → native machine code
- Happens per-method, lazily, as methods are first called
- Output is specific to the exact CPU it's running on
Why Does It Exist?
The Problem
Imagine .NET skipped IL entirely and Roslyn compiled C# straight to native x64 machine code, the way a C compiler does. That would create real problems:
- No portability — you'd need a separate build for x64, Arm64, Windows, Linux, macOS... every target would need its own compiled artifact.
- No runtime services — things like garbage collection, exception handling, and type safety checks need the runtime to understand what the code is doing structurally. Raw machine code has already thrown that structure away.
- No adaptive optimization — a compiler working ahead of time can't know which CPU features are actually available at runtime, or which methods will actually run hot in your specific deployment.
Now imagine the opposite extreme: the JIT does its absolute best optimization pass on every single method, the instant the program starts. That has a different problem — startup would be brutally slow, because full optimization (inlining, register allocation, loop unrolling) is expensive to compute, and most methods only run a handful of times.
The Solution
.NET's answer is layered:
- Compile to IL, not native code, at build time — this gives portability (write once, the same DLL runs anywhere .NET runs) and preserves the structural information the CLR needs for GC, type safety, and reflection.
- JIT lazily, per method, at run time — a method that's never called is never compiled at all. Startup only pays for the code paths actually exercised.
- Compile in tiers — get something running fast (Tier 0), then spend real optimization effort only on the methods that prove, by actually being called a lot, that the investment is worth it (Tier 1).
This is a deliberate trade-off between startup latency and peak throughput — and it's tuned automatically so you rarely have to think about it. You'll dig into that trade-off in detail in the next lesson.
Big Picture
- What you write in your editor
- Parses, type-checks, and lowers your C# into IL
- A CPU-independent, portable binary sitting on disk
- Locates and reads the assembly, resolves type metadata
- Tier 0 — fast, minimally optimized native code, generated quickly
- Tier 1 — fully optimized recompilation, only for methods called often enough
- The only form of code your processor can actually execute
Two alternative paths exist to this default JIT pipeline, and you'll meet them properly in a later module — for now, just know they exist:
ReadyToRun (R2R)
- Assemblies are pre-compiled to native code at publish time, in addition to still carrying IL
- The CLR can use that native code directly instead of JIT-compiling on first call — improving startup
- Tiered JIT can still re-optimize hot R2R methods later, using Dynamic PGO
- Still runs on the full CLR with GC, reflection, etc.
NativeAOT
- Compiles your entire app to a single native executable ahead of time — no IL, no JIT, no CLR loading step at run time
- Fastest possible startup, smallest memory footprint
- Trades away some dynamic features (heavy reflection, runtime code generation)
- Full depth is a dedicated Advanced lesson later in this book
How It Works
Step 1 — You write and build C#
public static int Square(int n) => n * n;
When you run dotnet build, Roslyn parses this into a syntax tree, resolves every symbol (what is n? what type does * mean here?), and checks it against the language rules. This is where compile errors like "cannot convert type" get caught — before your program ever runs.
Step 2 — Roslyn emits IL and metadata
Roslyn lowers your C# into IL, a stack-based, CPU-independent instruction set. The Square method above becomes something like:
IL_0000: ldarg.0
IL_0001: ldarg.0
IL_0002: mul
IL_0003: ret
Alongside the IL, Roslyn writes metadata — a structured catalog of every type, method signature, field, and attribute in the assembly. This metadata is what lets tools like reflection, the debugger, and IntelliSense understand your code's shape without re-parsing C# source.
Step 3 — The assembly is packaged
IL and metadata are packaged into a Portable Executable (PE) file — your familiar .dll or .exe. This file contains no CPU-specific instructions at all yet. The same assembly can, in principle, run on an x64 Windows server or an Arm64 Linux container without recompilation, as long as a compatible .NET runtime is present.
Step 4 — You run the program, and the CLR takes over
When you (or your hosting environment) launch the app, the dotnet host starts the CLR, which loads the entry assembly, resolves its dependencies, and prepares to run Main. No machine code has been generated yet — only IL has been loaded.
Step 5 — The JIT compiles methods on first call
The very first time Square is actually invoked, the CLR notices it has no native code for it yet, and hands the IL to the JIT compiler. By default, the JIT produces a Tier 0 version first: quick to generate, with minimal optimization. If Square turns out to be called repeatedly (it's "hot"), the runtime schedules a background recompilation to a fully optimized Tier 1 version, and future calls use that instead. Methods called only once or twice may simply stay at Tier 0 forever — spending optimization effort on them wouldn't pay off.
Step 6 — The CPU executes native code
Only now does actual machine code run — real x64 or Arm64 instructions, sitting in memory, executed directly by the processor. Everything before this step was preparation; this is the only step the hardware itself participates in.
Simple Example
Consider this tiny console app:
int total = 0;
for (int i = 0; i < 1_000_000; i++)
{
total += Square(i);
}
Console.WriteLine(total);
static int Square(int n) => n * n;
Code → What happens:
Mainis JIT-compiled at Tier 0 the moment the process starts andMainis first invoked by the runtime.Squareis JIT-compiled at Tier 0 the very first time the loop calls it (iteration 0).- After roughly 30 calls (the exact threshold is a runtime implementation detail, not a documented contract you should code against), the runtime notices
Squareis hot and queues it for Tier 1 recompilation on a background thread. - A moment later, calls to
Squaretransparently start using the new, fully optimized Tier 1 code — likely with the multiplication inlined directly into the loop, eliminating the call overhead entirely. - You never see any of this. It's invisible from C# — no code you write triggers or observes tiering directly.
Real-World Example
Picture an ASP.NET Core API deployed as a container that autoscales — new instances spin up in response to traffic spikes, run for a while, and then get torn down.
- When a new container starts, the CLR loads all the assemblies, and the JIT has to compile
Main, the ASP.NET Core middleware pipeline, routing, and your controllers — all before the first request can be handled. This is exactly why Tier 0 exists: it gets that first request served quickly instead of making the pod wait through full optimization of hundreds of startup-path methods. - Once the container is warm and serving thousands of requests per second, the handful of hot-path methods (JSON serialization, your busiest endpoint handler, a frequently-hit database query builder) get promoted to Tier 1, so steady-state throughput is just as good as if everything had been optimized from the start.
- If this same API is deployed as a serverless function that only lives for a few requests before being recycled, the container may never live long enough for Tier 1 to matter — which is exactly the scenario where teams reach for ReadyToRun or NativeAOT to skip JIT warm-up entirely.
Analogy
Imagine a play written in English (your C#) that needs to be performed for an audience whose language you won't know until showtime.
Roslyn is a translator who converts the script, well ahead of time, into a detailed universal notation — precise stage directions and dialogue that any actor, in any language, could follow (this is IL). This translation happens once, and the notation works for any theater.
The JIT is the cast, who only translate each scene into the audience's actual spoken language the moment that scene is about to be performed. For the first performance of each scene, they do a quick, serviceable read-through (Tier 0) so the show can start on time. If a scene turns out to be performed over and over (a hit number the audience demands as an encore), the cast rehearses it properly and delivers a polished, optimized version from then on (Tier 1).
The mapping back to code: the universal notation (IL) is portable to any "theater" (CPU architecture); the just-in-time translation (JIT) is what actually makes the show watchable in this specific room, for this specific audience — right now.
Under the Hood
- Same container format Windows uses for native executables (Portable Executable), reused by .NET.
- Instead of native machine code in the main code section, it holds IL.
- A metadata table catalogs every type, method signature, field, and custom attribute — this is what reflection reads at run time.
- An assembly manifest records the assembly's identity and the other assemblies it depends on.
- Before a method is ever called, the CLR points its entry to a small pre-stub — not real code, just a placeholder.
- The first call jumps into this stub, which triggers the JIT to compile the method and then patches the method's entry point to point at the freshly-generated native code.
- Every subsequent call goes straight to the native code — no stub, no recompilation, unless a tier promotion happens later.
- Tier 0 (QuickJit) — minimal optimization, very fast to produce; the CLR also inserts a call counter into the method.
- Once a method's call count crosses an internal threshold, it's queued for background recompilation.
- Tier 1 — the optimizing JIT runs: inlining, better register allocation, loop optimizations, and (starting in .NET 8) Dynamic PGO, which uses instrumentation collected from the method's actual Tier 0 execution to guide better decisions (e.g., which branch of an
ifis usually taken). - This all happens on background threads — your running request or loop is never blocked waiting for a recompile.
- Roslyn is a language compiler: it understands C# grammar, generics as written, LINQ query syntax, pattern matching — and turns all of that into IL once.
- The JIT is a code generation compiler: it doesn't know or care that your IL came from C# (it could have come from F# or VB.NET) — it only turns IL into CPU instructions, and it does this every time the process starts, on every machine that runs it.
- Roslyn's optimizations are mostly about correctness and some IL-shape simplifications; the JIT's optimizations are about real machine performance (inlining, register allocation, dead code elimination on the native side).
Common Confusion
1. "C# is a compiled language" — yes, but compiled twice
C# is neither "purely compiled" like C, nor "purely interpreted" like classic scripting languages. It's compiled ahead of time to IL (portable, but not directly runnable by hardware) and then compiled again, just-in-time, to native code. Calling it "interpreted" is wrong — the JIT genuinely produces real machine code, it just does so lazily and at run time rather than in advance.
2. "JIT compilation happens once, for the whole app, at startup"
It does not. JIT compilation is per method and lazy — a method is compiled the first time it's actually called, not when the assembly is loaded. A method your program never calls in a given run is never JIT-compiled at all. This is why cold-start profiles for large apps show JIT activity spread across the first several seconds (or longer), not concentrated in a single "startup compile" moment.
3. "ReadyToRun and NativeAOT are the same thing"
They solve a similar problem (slow JIT warm-up) very differently. R2R assemblies still carry IL and still run on the full CLR — the pre-compiled native code is just an optional fast-path the runtime can use instead of JIT-ing from scratch, and the JIT can still re-optimize hot R2R methods later. NativeAOT produces a single native executable with no IL, no JIT, and no separate CLR loading step at run time — it's a fundamentally different deployment model, covered in full later in this book.
Common Mistakes
Mistake 1 — Benchmarking a method's first call
Timing a method's very first invocation and concluding "this code is slow" — you likely measured Tier 0 JIT compilation time plus Tier 0 (unoptimized) execution, not steady-state performance.
Warm up the method with a few throwaway calls before timing it, or use a proper benchmarking tool (like BenchmarkDotNet) that accounts for JIT warm-up automatically.
Mistake 2 — Assuming the same IL always produces identical native code
Believing that because two machines run "the exact same DLL," they run identical machine instructions.
The JIT generates native code tailored to the actual CPU it's running on (available instruction set extensions, cache sizes considered by the optimizer, etc.). The IL is identical; the resulting native code can legitimately differ between machines.
Mistake 3 — Reaching for NativeAOT to "fix" every startup problem
Jumping straight to NativeAOT the moment cold start feels slow, without checking whether ReadyToRun or simply reducing startup-path work would be enough.
NativeAOT gives up some dynamic capabilities (heavy runtime reflection, certain plugin-loading patterns). Understand the trade-off — covered fully in its own lesson — before committing your deployment model to it.
Why Does This Matter for My Code?
You don't "turn on" this pipeline — it's always running underneath every C# program. But understanding it changes how you reason about real problems:
- Diagnosing cold-start latency in containers, serverless functions, or CLI tools — you now know to ask "how much of this is JIT warm-up?" before assuming it's your code's fault.
- Writing fair benchmarks — you know steady-state (Tier 1) performance and first-call (Tier 0) performance are genuinely different numbers, and both can matter depending on your scenario.
- Choosing a deployment model — long-running services (most APIs) benefit most from the default tiered JIT; short-lived, scale-to-zero workloads may benefit from ReadyToRun or NativeAOT.
- Understanding the rest of this book — garbage collection, memory allocation, and boxing all happen while native code runs. This lesson is the map that shows where that native code came from.
Mental Model
JIT = IL → native code, done per method, lazily, on the user's machine.
Remember:
· Two compilers, two very different jobs — language rules vs. machine code generation.
· Tier 0 gets you running fast; Tier 1 makes hot code fast to keep running.
· R2R pre-computes the Tier-0-ish native code at publish time; NativeAOT removes the JIT step entirely.
· None of this is something you write code to control directly — it's the invisible foundation everything else in this book sits on.
Key Takeaway
- C# executes in two stages: Roslyn compiles source to IL + metadata at build time; the JIT compiles IL to native code at run time, per method, on demand.
- This split gives .NET portability (one assembly, many CPUs/OSes) and lets the CLR provide services like GC, exception handling, and reflection that depend on structural metadata.
- Tiered compilation balances startup speed against peak performance: Tier 0 is fast-but-unoptimized, Tier 1 is slow-to-produce-but-fast-to-run, and only hot methods get promoted.
- ReadyToRun pre-generates native code at publish time to skip early JIT work while staying on the full CLR; NativeAOT removes JIT and the CLR loading step entirely for the fastest possible startup, at the cost of some dynamic features.
- This pipeline is the foundation for the rest of Part I — every GC pause, every allocation, every boxing operation happens inside native code that this pipeline produced.
Check Your Understanding
You've traced the full journey from C# source to executing machine code. Let's check that the mental model is solid before building on it.
1. What does Roslyn actually produce when it compiles your C# project?
Show answer
Correct: B
Why B is correct: Roslyn emits CPU-independent IL along with metadata describing types and members, packaged as a PE-format assembly (.dll/.exe). No native machine code is produced at this stage.
Why A is incorrect: That's the JIT's job, and it happens at run time on whatever machine actually executes the program — not at build time.
Why C is incorrect: The assembly still requires a .NET runtime (or NativeAOT publishing, a separate path) to actually execute — it isn't a standalone native binary by default.
Why D is incorrect: IL is a real binary instruction format, not re-parsed text, and the CLR compiles it to machine code — it doesn't interpret it line by line like a scripting engine.
Reinforcement: Build time and run time produce two different kinds of output — IL from Roslyn, native code from the JIT — and mixing them up is the single most common source of confusion about "compiled vs. interpreted" languages.
2. A method in your app is called exactly twice during the entire lifetime of the process. What is the most likely outcome for that method's JIT compilation?
Show answer
Correct: B
Why B is correct: Every called method is JIT-compiled on its first invocation — by default at Tier 0. Promotion to Tier 1 happens only after a method's call count crosses an internal threshold, which two calls won't reach, so it simply stays at Tier 0.
Why A is incorrect: A method has to be compiled before it can run at all — "called twice" still means it was JIT-compiled at least once, on the first call.
Why C is incorrect: The JIT deliberately does not fully optimize on the first call — that's the whole point of tiering; full optimization is reserved for methods proven to run often.
Why D is incorrect: There is no minimum call count required for a method to be compiled at all — only a threshold for tier promotion. A method called once still gets Tier 0 code.
Reinforcement: Most methods in a typical application are called rarely and simply live out their life at Tier 0 — tier promotion is reserved for the minority of methods that are actually hot.
3. Why does .NET compile IL to native code lazily, method by method, instead of compiling the entire loaded assembly to native code all at once at startup?
Show answer
Correct: B
Why B is correct: Lazy, per-method JIT compilation means startup cost scales with the code your program actually runs, not with the total size of the loaded assemblies. A large library with thousands of methods costs nothing at startup for the methods that are never called.
Why A is incorrect: There's no such technical limitation — the design choice is about efficiency, not capability.
Why C is incorrect: JIT compilation and garbage collection are unrelated processes; the GC managing heap memory has no bearing on when IL can be compiled.
Why D is incorrect: Roslyn's metadata is complete for every method in the assembly; the JIT could technically compile everything up front — the runtime simply chooses not to, for the startup-cost reason described in B.
Reinforcement: "Lazy" is the key word — the runtime avoids doing work that might never be needed, which is exactly why tiny console tools and huge web frameworks alike can start up reasonably fast despite having enormous amounts of IL loaded.
4. A team deploys the same published .NET assembly, unmodified, to both an x64 server and an Arm64 server. What should they expect?
Show answer
Correct: B
Why B is correct: IL is CPU-independent — the same assembly runs unmodified on both machines. Each machine's local JIT compiler generates native code appropriate to its own processor, so the underlying IL is shared but the final machine code differs.
Why A is incorrect: This is precisely the portability IL is designed to provide — a correctly targeted .NET assembly runs on any supported architecture with a compatible runtime installed.
Why C is incorrect: There's no native-code emulation happening — each machine's JIT compiles the IL itself, from scratch, for its own architecture. No x64 code ever needs to run on the Arm64 box.
Why D is incorrect: Recompiling with Roslyn per architecture is unnecessary for standard JIT-based deployment — that requirement only applies to certain NativeAOT publish scenarios, a separate ahead-of-time compilation model covered later.
Reinforcement: "Compile once, run anywhere .NET runs" is only true because the JIT — not Roslyn — is the piece of the pipeline responsible for producing CPU-specific code, and it does that work locally on each machine.
5. A serverless function using .NET has a strict cold-start budget and only ever handles one or two requests before its container is torn down. Which statement best explains why the default tiered JIT pipeline is a poor fit here?
Show answer
Correct: B
Why B is correct: A process that lives for one or two requests never runs long enough to benefit from Tier 1 optimization, yet it still pays full JIT compilation cost on every fresh cold start — cost that ReadyToRun or NativeAOT could largely avoid by having native code ready ahead of time.
Why A is incorrect: There's no such minimum — a method compiles on its very first call, whether that's call number one or call number one-thousand.
Why C is incorrect: Tiered JIT compilation works across all platforms .NET supports, including Linux — it isn't a Windows-only feature.
Why D is incorrect: The garbage collector functions normally in short-lived processes; this scenario is about JIT warm-up cost, not garbage collection, which is covered starting in the next few lessons.
Reinforcement: This is exactly the scenario where the startup-vs-throughput trade-off tips toward pre-compilation — short-lived processes never get to "cash in" on Tier 1's investment, so paying the JIT cost repeatedly on every cold start is pure overhead.
You now understand the pipeline that turns your C# into running machine code — the foundation for everything the CLR does next.