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.
Running a C# program is a two-stage translation, not one:
.cs files into IL (Intermediate Language) — a CPU-independent instruction set — and packages it into an assembly (a .dll or .exe) along with metadata describing every type, method, and field.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.
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).
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:
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.
.NET's answer is layered:
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.
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:
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.
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.
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.
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.
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.
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.
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:
Main is JIT-compiled at Tier 0 the moment the process starts and Main is first invoked by the runtime.Square is JIT-compiled at Tier 0 the very first time the loop calls it (iteration 0).Square is hot and queues it for Tier 1 recompilation on a background thread.Square transparently start using the new, fully optimized Tier 1 code — likely with the multiplication inlined directly into the loop, eliminating the call overhead entirely.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.
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.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.
if is usually taken).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.
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.
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.
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.
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.
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.
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:
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?
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?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.