Why the CLR comes up in interviews
Senior .NET interviews almost always start here. Understanding the runtime is what
separates developers who know how to use C# from developers who understand why it
behaves the way it does. Performance problems, unexpected memory usage, and subtle
threading bugs all trace back to CLR behaviour. If you cannot explain what happens
between writing Console.WriteLine("hello") and the CPU executing it, interviewers
know the gaps in your mental model.
From C# source to native CPU instructions
The journey a .NET program takes before any instruction reaches the CPU:
C# source (.cs)
│ csc / Roslyn compiler
▼
Assembly (.dll / .exe) containing:
- IL bytecode ← language-agnostic instructions
- Metadata ← type descriptions, method signatures
- Manifest ← assembly identity, version, references
│
│ At runtime: CLR loads the assembly
▼
JIT Compiler (RyuJIT)
│ method-by-method, first time each is called
▼
Native machine code (x86-64, ARM64…)
│ cached for the lifetime of the process
▼
CPU execution
The key insight is the intermediate step: .NET compilers do not produce native code. They produce IL (Intermediate Language) — a stack-based, CPU-agnostic bytecode that the CLR understands. This is what makes .NET polyglot: C#, F#, and VB.NET all compile to the same IL, and can call each other's assemblies without any wrappers.
What IL looks like
IL is a low-level, typed, stack-based instruction set. Every operation pushes or pops
from the evaluation stack. You rarely read IL directly, but tools like dotnet-ildasm,
ILSpy, or dnSpy let you inspect it.
// C# source:
static int Add(int a, int b) => a + b;
The compiler emits roughly:
.method private hidebysig static int32 Add(int32 a, int32 b) cil managed
{
ldarg.0 // push 'a' onto evaluation stack
ldarg.1 // push 'b' onto evaluation stack
add // pop two values, push their sum
ret // return top of stack
}
This IL is the same regardless of whether you are running on Windows x64, Linux ARM64, or macOS. The JIT compiler translates it to the platform-appropriate native instructions at runtime.
The JIT compiler — RyuJIT
.NET 5+ uses RyuJIT as the single cross-platform JIT compiler. When a method is first called, the CLR's JIT stub is invoked, which:
- Reads the IL for that method from the assembly
- Verifies it for type safety and stack consistency
- Generates native code for the current CPU architecture
- Patches the call site so future calls go directly to native code
- Caches the native code for the process lifetime
The second call to the same method incurs zero JIT cost — it goes directly to the cached native code.
Tiered compilation
Since .NET Core 3.0, the JIT uses tiered compilation to balance startup speed with peak throughput:
- Tier 0: JIT compiles quickly with minimal optimisation. Fast startup. The method is instrumented with a call counter.
- Tier 1: Once a method crosses a call threshold, it is queued for full recompilation with aggressive optimisations (inlining, loop unrolling, register allocation). This happens on a background thread; the old tier-0 code stays active until tier 1 is ready, then call sites are patched atomically.
The result: .NET applications start quickly (tier 0) and reach high throughput on hot paths (tier 1) without developer intervention. Tiered compilation is why .NET server applications warm up relatively quickly and then stabilise at their peak performance.
ReadyToRun (R2R) and Native AOT
ReadyToRun (set <PublishReadyToRun>true</PublishReadyToRun>) pre-compiles IL to
native code at publish time, reducing JIT work on startup. The native code is embedded
in the assembly alongside the IL, so tier-1 recompilation can still occur at runtime.
Native AOT (<PublishAot>true</PublishAot>) eliminates the JIT entirely: the
entire application is compiled to native code ahead of time. No CLR is needed at
runtime. The trade-off is a larger binary and no runtime profile-guided optimisation.
The Common Type System and the Common Language Specification
The Common Type System (CTS) is the ruleset that defines every type the CLR
understands. All .NET languages must compile their types to CTS-compliant types, which
is what enables cross-language interop. C# int = VB.NET Integer = F# int = CTS
System.Int32. They are literally the same type.
The Common Language Specification (CLS) is a more restrictive subset. It defines
the minimum a .NET language must support to interoperate with any other CLS-compliant
language. Public APIs should be CLS-compliant: no uint parameters (VB.NET has no
unsigned integers), no case-only identifier differences, no global functions.
Annotate your library with [assembly: CLSCompliant(true)] to get compiler warnings
on CLS violations.
Assemblies — the unit of deployment
An Assembly is the compiled, versioned, deployable unit of .NET code. It is
typically a .dll or .exe file but is more than just a binary:
- IL bytecode — the compiled code for all types in the assembly
- Manifest — assembly identity (name, version, culture, public key token), list of referenced assemblies, and list of modules
- Type metadata — full type descriptions: class names, method signatures, field names, custom attributes — consumed by reflection and the JIT
- Resources — embedded images, strings, localisation files (optional)
The manifest is what the CLR's assembly resolver uses to locate the right version of a dependency. Strong-named assemblies include a cryptographic public key token in their identity, enabling side-by-side deployment of different versions.
In modern .NET, NuGet packages are the distribution unit; they contain one or more
assemblies plus metadata. A project reference (<ProjectReference>) becomes an assembly
reference; a package reference (<PackageReference>) resolves to the package's
assembly or assemblies.
Garbage collection — the generational model
The CLR GC is a tracing, generational, compacting collector. It works by:
- Marking all objects reachable from GC roots (static fields, stack variables, CPU registers, GC handles)
- Sweeping unreachable objects (reclaiming their memory)
- Compacting the heap to eliminate fragmentation (sliding live objects together)
Generations
Most objects die young — a local variable created for a single method call lives only microseconds. The GC exploits this with three generations:
| Generation | Contents | Collection frequency |
|---|---|---|
| Gen 0 | Newly allocated objects | Very frequent (sub-millisecond) |
| Gen 1 | Survived one Gen 0 collection | Moderate |
| Gen 2 | Long-lived objects | Rare, most expensive |
When Gen 0 fills up, a Gen 0 collection runs. Objects that survive are promoted to Gen 1. A Gen 1 collection collects Gen 0 and Gen 1. A full (Gen 2) collection collects all generations and compacts the heap — this is the most expensive and causes the most noticeable pauses.
Large Object Heap
Objects larger than ~85 KB are allocated on the Large Object Heap (LOH), which is collected with Gen 2 but not compacted by default (compacting large objects is expensive). LOH fragmentation is a common source of memory growth in long-running services that repeatedly allocate large byte arrays.
Fix: use ArrayPool<byte>.Shared to rent and return large buffers rather than
allocating new ones on every request.
Background GC
.NET's default Server GC and Workstation GC both support background GC, where the Gen 2 collection runs on dedicated background threads, allowing your application threads to continue running concurrently for most of the collection.
Managed vs unmanaged code
Code running under CLR supervision is managed — the runtime handles memory, type safety, and exception propagation. Code that runs directly on the OS without CLR overhead is unmanaged — typically native C/C++ libraries or OS APIs.
.NET interacts with unmanaged code through:
- P/Invoke (Platform Invocation Services) — calling C functions in
.dll/.sofiles - COM Interop — calling or hosting COM components
unsafeblocks — pointer arithmetic within managed code (CLR still runs but safety checks are suspended for theunsaferegion)
// P/Invoke example — calling a Win32 API directly:
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
.NET Framework vs .NET Core vs modern .NET
This comes up in almost every .NET interview:
| .NET Framework | .NET Core | .NET 5+ | |
|---|---|---|---|
| Released | 2002 | 2016 | 2020 |
| Platform | Windows only | Cross-platform | Cross-platform |
| Open source | No | Yes | Yes |
| Status | Maintenance only (v4.8.1 final) | Merged into .NET 5 | Active (annual releases) |
| Side-by-side versions | No | Yes | Yes |
Modern .NET (5, 6, 7, 8, 9) unified .NET Core and .NET Framework into a single platform. Even-numbered releases (6, 8, 10…) are LTS (3-year support); odd-numbered (7, 9…) are STS (18-month support). For new projects, target the current LTS.
.NET Framework is not dead — Microsoft maintains it for backwards compatibility — but it receives no new features. If you are writing a new project or migrating, target modern .NET.
Reflection and source generators
Reflection allows .NET code to inspect type metadata at runtime: enumerate methods and properties, read custom attributes, and invoke members by name. It powers DI containers, ORMs, serialisers, and test frameworks. The downside is performance overhead and loss of compile-time safety.
Source generators (C# 9+, Roslyn) generate code at compile time as an alternative
to runtime reflection. System.Text.Json's JsonSerializerContext, EF Core's compiled
models, and the [GeneratedRegex] attribute all use source generators to produce
zero-overhead, AOT-compatible code. When you see [JsonSerializable(typeof(MyType))]
in modern .NET code, that is a source generator at work.
Recap
The CLR is the execution engine for all .NET code. C# compiles to IL, which the JIT compiler (RyuJIT) translates to native code on first call, caching the result for the process lifetime. Tiered compilation starts methods at tier 0 (fast JIT) and promotes hot methods to tier 1 (full optimisation) on a background thread. The Common Type System ensures all .NET languages share the same type model, enabling cross-language interop. Assemblies are the unit of versioning and deployment — they carry IL, metadata, and a manifest. The generational GC collects short-lived objects cheaply in Gen 0 and reserves full-heap compaction for rare Gen 2 collections. Understanding these fundamentals is what lets you reason confidently about performance, memory, and the difference between .NET versions.