How is C# code compiled and run in .NET? Explain the CLR, IL and the JIT, and the difference between managed and unmanaged code.
20 reports in the question index (8 linked below), first technical rounds: Infosys (5, two of them in Aug–Sep 2026 for 3+ and 4+ YOE), Wipro (2, "what runs the program?"), Deloitte (2), IntraEdge, Accenture and others.
Say this first · about 30 seconds
C# is compiled twice. First, at build time, the C# compiler, Roslyn, turns my code into IL, intermediate language, which is the same on every kind of processor. That goes into an assembly, the DLL file.
Then at run time the CLR, the Common Language Runtime, loads that assembly. The first time a method is called, the JIT, the just-in-time compiler, turns its IL into machine code for the exact CPU the app is running on, and caches it, so later calls run that native code directly.
Modern .NET does this in tiers: a quick, lightly optimised compile first so the app starts fast, then a fully optimised one in the background for methods that are called a lot. If start-up time matters, ReadyToRun puts precompiled code in the DLL, and Native AOT compiles the whole app ahead of time with no JIT at all.
Managed code is code the CLR runs and looks after: it checks types, frees memory with the garbage collector and turns faults into exceptions. Unmanaged code is native code outside the CLR, like a C library or a Windows API. We call it through P Invoke, which is how C# calls into a native library, and the garbage collector can't free its memory or handles, so we release them ourselves with Dispose, or a SafeHandle, a wrapper class that frees a native handle for us.
Where it mattered for us: after every deploy, our Navistar quote API was slow for a couple of minutes because about fourteen hundred methods were being JIT compiled on every fresh instance. Deploying to a , a second copy of the app, and sending it a warm-up request before real users arrive, plus ReadyToRun, fixed it.
Make it yours · UDC Finance direct-debit files, signed by a native library (invented example)
Situation: at UDC Finance in New Zealand, each night we send the bank a direct-debit file: which customers to charge for their instalments. The bank requires each file to be signed with its own library, written in C, not .NET. That is unmanaged code: the CLR doesn't run it and the garbage collector can't see the memory it uses.
What I did: I called it from our .NET 8 batch job with P/Invoke (Platform Invoke: a C# method marked [DllImport] that jumps into a native library). The library hands back a handle (a number that stands for memory it holds). I wrapped that handle in a , so it is always freed, even when signing throws.
Result: the earlier version freed the handle only on success. Every failed signature leaked native memory, and on a bad night the job's memory crept up until restarted it. With the memory stays flat.
What I'd say: managed code gets the GC for free; anything native you open, you close. A makes that happen even on an exception.
The code, simplified
Invented to show the shape. Tap Next to follow one file being signed: into native code and back.
UDC.sln · where these 2 files sit
Debits/
Diagram · step 1The job opens a signer. The library returns a handle; the CLR wraps it in a SignerHandle.
Batch jobmanaged C#
CLRP/Invoke
Bank librarynative C
Open()
handle 0x7F3A
new SignerHandle
DebitFileSigner.cs · line 3 · 1 of 4using: when the method ends, normal or by error, h is freed. .
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
C# is compiled twice. First, at build time, the C# compiler, Roslyn, turns my code into IL, intermediate language, which is the same on every kind of processor. That goes into an assembly, the DLL file.
Then at run time the CLR, the Common Language Runtime, loads that assembly. The first time a method is called, the JIT, the just-in-time compiler, turns its IL into machine code for the exact CPU the app is running on, and caches it, so later calls run that native code directly.
Modern .NET does this in tiers: a quick, lightly optimised compile first so the app starts fast, then a fully optimised one in the background for methods that are called a lot. If start-up time matters, ReadyToRun puts precompiled code in the DLL, and Native AOT compiles the whole app ahead of time with no JIT at all.
Managed code is code the CLR runs and looks after: it checks types, frees memory with the garbage collector and turns faults into exceptions. Unmanaged code is native code outside the CLR, like a C library or a Windows API. We call it through P Invoke, which is how C# calls into a native library, and the garbage collector can't free its memory or handles, so we release them ourselves with Dispose, or a SafeHandle, a wrapper class that frees a native handle for us.
Where it mattered for us: after every deploy, our Navistar quote API was slow for a couple of minutes because about fourteen hundred methods were being JIT compiled on every fresh instance. Deploying to a , a second copy of the app, and sending it a warm-up request before real users arrive, plus ReadyToRun, fixed it.
Check your answer: tick what you said
You said 0 of 8
Then add your example: UDC Finance direct-debit files, signed by a native library (invented example)
Situation: at UDC Finance in New Zealand, each night we send the bank a direct-debit file: which customers to charge for their instalments. The bank requires each file to be signed with its own library, written in C, not .NET. That is unmanaged code: the CLR doesn't run it and the garbage collector can't see the memory it uses.
What I did: I called it from our .NET 8 batch job with P/Invoke (Platform Invoke: a C# method marked [DllImport] that jumps into a native library). The library hands back a handle (a number that stands for memory it holds). I wrapped that handle in a , so it is always freed, even when signing throws.
Result: the earlier version freed the handle only on success. Every failed signature leaked native memory, and on a bad night the job's memory crept up until restarted it. With the memory stays flat.
What I'd say: managed code gets the GC for free; anything native you open, you close. A makes that happen even on an exception.
See it
Native buildC++: one CPU, one OS
CPUx64 or Arm64
pricing.exe: Windows x64 only
1/8Before .NET: C or C++ compiled straight to one CPU's machine code, for one OS.A build per CPU and OS, and nothing guards memory: one bad pointer crashes the app.Fix, step 1: Roslyn compiles C# to IL plus metadata, into a DLL. Same file for every CPU.Run time: the CLR loads the DLL and reads its metadata. No machine code exists yet.First call of a method: the JIT compiles its IL to machine code for this CPU.Second call: the machine code is cached. No JIT, about 50 times faster here.Managed: the CLR checks types and the GC frees objects nobody uses any more.Build: C# → IL once. Run: CLR loads it, JIT makes machine code per method, once per process.
In plain words
A computer's processor (CPU) only understands machine code: numbers that mean "add these", "jump there". An Intel laptop and an Arm laptop understand different machine code. So someone has to turn your C# into the machine code of the computer it runs on.
.NET does it in two steps. Step 1, when you build: the C# compiler (Roslyn) turns your code into IL, intermediate language. IL is like machine code for an imaginary processor: the same on every computer. It goes into a DLL (or EXE), called an assembly, with metadata: a list of every class and method inside.
Step 2, when you run: the CLR, the Common Language Runtime, is the engine that runs .NET apps. It loads your assembly. The first time a method is called, its JIT (just-in-time compiler) turns that method's IL into real machine code for this computer, right then, and keeps it in memory. The second call skips the JIT.
Tiers: the first JIT is quick and simple, so the app starts fast. If a method is called many times, the JIT compiles it again, properly optimised, in the background. This is tiered compilation.
Skipping the JIT at start-up:ReadyToRun stores machine code inside the DLL in advance, so first calls are faster. Native AOT (ahead of time) compiles the whole app to machine code before it ships; there is no JIT at all.
Managed code is code the CLR runs and looks after: it checks types, catches bad memory use, and its garbage collector (GC) frees objects you no longer use ().
Unmanaged code is native machine code the CLR doesn't run, like a library written in C, or a Windows function. C# can call it (P/Invoke), but the GC can't see the memory it uses. You must free it yourself.
The senior answer · about 2 minutes
What happens, in order
At build time, Roslyn, the C# compiler, turns my code into IL, which is like assembly language for an imaginary CPU, and writes it into the DLL with metadata: every class, method and field, so the runtime and tools know exactly what is inside.
At run time the CLR loads the DLL. A method doesn't become machine code until its first call: the JIT compiles it right then, for this exact processor, x64 (Intel and AMD chips) or Arm64, and keeps the result in memory. Every later call jumps straight to that native code.
Why this design
One build runs on any CPU and OS that has the runtime, which is why the same DLL runs on our Windows laptops and on Linux . And because the CLR sees all the code through IL and metadata, it can check types, run the garbage collector and turn a bad memory access into a clean exception instead of a crash.
The cost is start-up. On a fresh process, every method pays its JIT once. Tiered compilation keeps that cheap with a quick first compile and re-optimises the hot methods later, and since .NET 8, Dynamic PGO uses what the app actually did to optimise them better.
What I do about it in production
Cold starts show up after deploys and scale-outs. We deploy to a and let send warm-up requests before the swap, so the JIT cost is paid before customers arrive. For start-up-sensitive services we publish with ReadyToRun; Native AOT only where reflection-heavy libraries aren't needed, because it has no JIT to fall back on.
I measure, not guess: shows the methods-jitted count and the IL bytes jitted. That is how we proved the post-deploy slowness was JIT; it is the production incident further down.
Managed versus unmanaged
Everything the CLR runs is managed: memory is freed by the GC, array bounds are checked, types are verified. Unmanaged code, a C library or a Windows API, runs outside that safety net.
When I call native code with P/Invoke, anything it allocates is my job. I wrap native handles in a and dispose them with using, because a forgotten handle is a leak the GC will never see. The bank signing library at UDC is my example.
One request, cold and warm
The Navistar quote API right after a deploy, then a minute later. Each step names the runtime piece at work.
milliseconds after the request arrives (time axis cut before quote #500)
Request
JIT
Machine code runs
quote #1
900 ms
1/5Cold. The first quote request after a deploy reaches a brand-new process.JIT. About 1,400 methods compiled on first use: routing, JSON, EF Core (database), ours.Run. Only then does the quote's machine code run. Answer at about 7.8 s, before the fix.Warm. A minute later (axis cut), quote #500: no JIT. About 180 ms, all database time.Same code, same server. The difference is the JIT, paid once per method per process.
The story
Before
Navistar Financial's pricing engine used to be a C++ program, compiled straight to Windows x64 machine code (the numbers a processor actually runs). It was fast. But it ran only on that one kind of computer, and C++ makes you free every piece of memory yourself. The fix, below, rewrites it in C#: the build makes a DLL (the file of compiled code your build produces), and the CLR (the .NET engine that runs it) guards its memory.
pricing.cppC++ source
pricing.exeWindows x64 code
1/4Before: C++ compiled straight to machine code for Windows on x64 chips.A move to Linux or Arm servers means a new build, and often code changes.A bug used memory after freeing it. The whole process crashed, with no error to catch.Fix: rewrite it in C#. One DLL for every machine; the CLR guards memory.
Why it had to change
Compiling straight to machine code ties a program to one kind of processor and gives it no safety net. .NET puts a middle step in: a neutral code that a runtime turns into machine code on the spot, and watches while it runs.
How it works now
That neutral code is IL, stored with metadata (a list of the types and methods inside), and the runtime is the CLR.Roslyn is the C# compiler that writes the IL; the JIT is the part of the CLR that turns IL into machine code. Here is one method's life, from build to its second call:
Roslynat build
DLLIL
CLR + JITat run time
CPUruns machine code
IL + metadata
1/5Build: Roslyn compiles each C# method into IL and writes it into the DLL.Start: the app runs, and the CLR loads the DLL. Still no machine code for our method.First call: the JIT reads the method's IL and writes machine code for this CPU.Second call: the CPU runs the cached machine code. The JIT isn't involved.Called about 30 times: the JIT recompiles it, fully optimised, in the background.
In the lending API
The C# you write. Deep shows the IL the compiler turns it into.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/Domain/
⋯ 1 line in Deep
QuoteMath.cs · line 3 · 1 of 2Months still to pay on a contract: 36-month term, 10 paid, 26 left. You write C#; the build turns it into IL.
The code, file by file
One tiny method from the lending API, the IL Roslyn makes from it, and a caller that times the JIT. Tap Next to walk the diagram with the code.
Lending.sln · where these 3 files sit
src/
Domain/
Api/
Diagram · step 3Fix, step 1: Roslyn compiles C# to IL plus metadata, into a DLL. Same file for every CPU.
Native buildC++: one CPU, one OS
CPUx64 or Arm64
pricing.exe: Windows x64 only
QuoteMath.cs · line 3 · 1 of 2Months still to pay on a contract: 36-month term, 10 paid, 26 left. You write C#; the build turns it into IL.
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedWhat is managed code, and what is unmanaged code?
Managed code runs under the CLR: it is compiled to IL, JIT-compiled, type-checked, and its memory is freed by the garbage collector. Unmanaged code is native machine code the CLR doesn't control, like a C or C++ library, a Windows API or a COM component (Windows' older way for programs to call each other). You call it through P/Invoke or COM interop, and you free what it allocates yourself, best with a and using.
The CLR. dotnet MyApp.dll (or the app's own launcher) starts the runtime, which loads the assembly, finds (or the top-level statements in Program.cs), JIT-compiles it and runs it. From then on the CLR JIT-compiles each method on its first call, runs the garbage collector, manages threads and turns faults into exceptions.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedWhat is managed code, and what is unmanaged code?
Managed code runs under the CLR: it is compiled to IL, JIT-compiled, type-checked, and its memory is freed by the garbage collector. Unmanaged code is native machine code the CLR doesn't control, like a C or C++ library, a Windows API or a COM component (Windows' older way for programs to call each other). You call it through P/Invoke or COM interop, and you free what it allocates yourself, best with a and using.
The CLR. dotnet MyApp.dll (or the app's own launcher) starts the runtime, which loads the assembly, finds (or the top-level statements in Program.cs), JIT-compiles it and runs it. From then on the CLR JIT-compiles each method on its first call, runs the garbage collector, manages threads and turns faults into exceptions.
Older material lists three: pre-JIT (all at once, via NGen), econo-JIT and normal JIT. That is .NET Framework-era. In modern .NET the useful answer is: tiered compilation (quick tier 0, then optimised tier 1 for hot methods), ReadyToRun (precompiled code in the DLL, JIT still there), and Native AOT (no JIT at all). NGen still exists on .NET Framework only.
When would you use Native AOT?
When start-up time and memory matter more than flexibility: a small API in containers that scale from zero, a command-line tool, an Azure Function where cold starts hurt. Not when the app leans on reflection or generates code at run time, as many older libraries do, because there is no JIT to fall back on. ASP.NET Core supports it from .NET 8, mainly for minimal APIs.
ReportedWhat is an assembly? What types are there?
An assembly is the unit .NET deploys and loads: a DLL or EXE with IL, metadata and a manifest (its name, version and what it depends on). Private assemblies sit in the app's own folder; that is how .NET Core and later work. Shared assemblies were installed in the GAC (Global Assembly Cache), which exists only on .NET Framework. Satellite assemblies hold translated text and images for one language.
The CTS (Common Type System) defines the types every .NET language shares, so a C# int and an F# int are both . The CLS (Common Language Specification) is the smaller set of rules a library follows so every .NET language can use it, like not relying on unsigned types in public methods.
Is a .NET app slower than C++ because of the JIT?
At start-up, a little, because methods are compiled on first use. Once warm, often not: the JIT compiles for the exact CPU it runs on, and tier 1 with Dynamic PGO optimises using what the app actually did. For start-up we use ReadyToRun or Native AOT. In practice our latency is decided by the database and network calls, not by the JIT.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
"The new formula is 40 times slower" (it wasn't)
Stopwatch, Debug build, one call each:
Old formula: 0.02 ms
New formula: 0.81 ms
Cause
A developer timed each formula with one call in a Debug build. The new formula was timed first after start-up, so its time included the JIT compiling it, plus the methods it calls. Debug builds are also not optimised. The "slowness" was one-time JIT cost.
Fix
Measure in a Release build (the optimised build you ship; a Debug build is unoptimised, for stepping through code), call each formula many times first (warm-up) so the JIT is done, then time many calls. Better: BenchmarkDotNet, a library that does the warm-up, repeats and statistics for you.
Before · FormulaTiming.cs · line 1 · 1 of 2: a precise clock. starts it.
Say it
The first call of any .NET method includes its JIT. So I never time one call: Release build, warm up, many runs, or BenchmarkDotNet. The new formula turned out to be within 3% of the old one.
Native memory leak on failed signatures
App Service: memory 1.1 GB → 3.4 GB over 6 hours
Process restarted (out of memory)
.NET GC heap: steady at 180 MB
Cause
The handle from the bank's C library was freed only on the success path. When signing threw, the code skipped , and the native memory stayed taken. The GC heap looked fine because the GC only counts managed memory; the leak was outside it.
Fix
Wrap the handle in a subclass and open it with using. Disposal now runs on success and on error, and if someone forgets, .NET still releases it when the object is collected.
Diagram · step 1Before: open a handle, sign, then close. Fine while signing works.
Batch jobmanaged
Bank librarynative memory
Open → handle
Before · DebitFileSigner.cs · line 1 · 1 of 2: a raw native address. Nothing in .NET knows it must be freed.
Say it
Managed memory looked flat but the process grew: that pattern means a native leak. The handle was freed only on success. A with using frees it on every path.
Production incident: Every deploy made truck quotes time out for two minutes
P3 · minorNavistar Financialcame with a deployQuote API (.NET 8, Linux App Service, 3 instances) behind the Navistar dealer portal
Composed, not a real event at Navistar Financial. The mechanism is real: a new .NET process JIT-compiles each method on its first call, so the first requests after a restart are slow; App Service can warm a staging slot before a swap, and ReadyToRun cuts JIT work at start-up (Microsoft Learn; JIT counts checked with `JitInfo` on .NET 10). Times, counts, names, ticket and PR numbers are composed.
The story in 5 lines
For weeks, Navistar dealers saw truck quotes fail with "try again" for about two minutes after each Tuesday release. Then everything was normal.
Releases deployed straight to the production , which restarted all three instances at once. Every instance then served real requests from a brand-new process.
A new .NET process compiles each method with the JIT on its first call. The first quotes paid for about 1,400 methods each: up to 9 seconds, past the portal's 8-second timeout.
The fix: deploy to a , let send warm-up requests to it, then swap. The quote API is also published with ReadyToRun, so less is compiled at start-up.
A dashboard now shows methods compiled and response time per instance after each release.
Who's who (4 people)
You
.NET developer on the vendor team. Measures, finds the cause, changes the deploy.
Tech lead
Vendor senior. Owns the release pipeline, reviews the change.
L2 pod
Vendor support. Watches alerts, runs log queries during releases.
Tap a step. The map lights the place where it happens.
Truck quotesNavistar dealers
Instance 1production
Instance 2production
Instance 3production
19:02–19:04 CT
quote errors: 18%
p95: 9.1 s
1/8 Detect
Detect: right after a release, quote errors jump to 18% for two minutes, then stop.
Tue 3 Mar 2026, 19:02 CT (Wed 06:32 IST, India time)Navistar dealer support, then the L2 pod
What: dealer support reports that truck quotes "fail on Tuesday evenings". Times are US Central (CT), where Navistar's dealers work. matches the times to our weekly 19:00 release.
Why it hid: each burst lasted two minutes, and the error-rate alert needed 5 minutes above 5% to fire. Retries worked, so most dealers never called.
How we saw it: the query below in , the query language of (Azure's store for the app's requests and errors), split by instance and by minute.
App Insights: slow and failed quotes per minute
requests
| where timestamp between
(datetime(2026-03-04 01:00)
.. datetime(2026-03-04 01:15))
| where name == "POST /quotes"
| summarize p95 = percentile(duration, 95),
failed = countif(success == false)
by cloud_RoleInstance, bin(timestamp, 1m)
In theory
An alert sensitive enough to catch a short burst right after every release.
In practice
The 5-minute window hid a 2-minute problem for seven releases.
Try it yourself · 10 minutes, on your Mac
dotnet new console -n JitLab. In Program.cs, call a small method twice, timing each call with and printing before and after.
dotnet run -c Release: the first call is dozens of times slower and compiles one method; the second compiles none.
dotnet publish -c Release -r osx-arm64 -p:PublishReadyToRun=true -o r2r, then run ./r2r/JitLab: fewer methods compiled on the first call.
You should see
The JIT runs once per method per process. A restart means paying it again, so warm up before real traffic arrives.
The senior move
"After a deploy, the JIT cost goes to whoever sends the first request. We make sure that is our warm-up ping, not a customer."
SourcesSourced: methods are JIT-compiled on first call per process; ReadyToRun precompiles code into the assembly (Microsoft Learn, "ReadyToRun deployment"); warms a before swap and WEBSITE_SWAP_WARMUP_PING_PATH sets the warm-up path (Microsoft Learn, "Set up staging environments"); System.Runtime JIT counters. JIT counts checked with JitInfo on .NET 10. Composed: Navistar's systems, dates, counts, times, names, ticket and numbers, and the (standard columns).
Traps
Don't sayC# is an interpreted language.SayIt is compiled twice: to IL at build, to machine code by the JIT at run time. Nothing is interpreted line by line.
Don't sayThe JIT compiles the whole program when it starts.SayPer method, on its first call. A method never called is never compiled.
Don't sayThe garbage collector frees everything, so there are no leaks in .NET.SayIt frees managed memory only. Native handles and memory need or a .
Don't sayPre-JIT, econo-JIT and normal JIT are the JIT types.SayThat is .NET Framework history. Today: tiered compilation, ReadyToRun and Native AOT.
Traps
What should you say instead? Tap to check.
Don't sayC# is an interpreted language.
Say instead
It is compiled twice: to IL at build, to machine code by the JIT at run time. Nothing is interpreted line by line.
Don't sayThe JIT compiles the whole program when it starts.
Say instead
Per method, on its first call. A method never called is never compiled.
Don't sayThe garbage collector frees everything, so there are no leaks in .NET.
Say instead
It frees managed memory only. Native handles and memory need or a .
Don't sayPre-JIT, econo-JIT and normal JIT are the JIT types.
Say instead
That is .NET Framework history. Today: tiered compilation, ReadyToRun and Native AOT.
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Question index: Musteasy · 10 minutes
Show that the JIT happens on a method's first call: time the first and second call of a small method and print how many methods were JIT-compiled in between.
Which attribute tells .NET a method's body lives in a native library?
On Linux and macOS the C library is called libc; on Windows the same idea is in kernel32.
Show a solution
Lending.sln · where this file sits
Diagram · step 2On the call, the CLR finds libc, finds getpid and jumps into native code.
Program.csmanaged
CLRP/Invoke stub
libcnative C
static extern int getpid()
Program.cs · line 1 · 1 of 4Looks like a normal C# call; Native is the class below. The CLR does the native jump.
What the interviewer checks
[DllImport("libc")] on a static extern method.
Parameter and return types that match C (int).
Can say what the CLR does at the call: marshalling, converting values between .NET types and C types and that the GC doesn't track native memory.
Senior extra: mentions LibraryImport, the newer source-generated form.
With AI tools
Interviewers ask how you check what an assistant tells you about the runtime. Chat answers about "JIT types" still repeat the old pre-JIT / econo-JIT list, and performance claims are often guesses. I ask the assistant to write the BenchmarkDotNet test or the command, and I trust the numbers it produces on our build, not the paragraph. For P/Invoke code an assistant writes, I check the types match the C header and that every native handle is in a .