Every .NET performance conversation eventually runs into the JIT, and senior interviews use it to test something more specific than "do you know what just-in-time compilation means." They're checking whether you understand that "the JIT" in modern .NET is not one event but an ongoing policy — code gets upgraded, replaced and sometimes recompiled mid-loop — and whether you can reason about the real trade-offs between letting that policy run, precompiling ahead of time, or removing the JIT from the picture entirely with Native AOT. This page works through tiered compilation, on-stack replacement, Dynamic PGO, ReadyToRun and AOT with the level of mechanism a staff-level interviewer expects, including the startup-latency calculus that actually drives these decisions in production.
Q1 What is tiered compilation, and what specifically triggers a method's promotion from Tier 0 to Tier 1?#
Short answer: Tiered compilation lets the runtime start with a fast-to-produce, lightly optimized Tier 0 body for a method — either Quick JIT output or a precompiled ReadyToRun body — and replace it in the background with a fully optimized Tier 1 body once the method proves itself hot, which is decided by a call counter (roughly 30 invocations) combined with a startup-detection timer, not by a fixed schedule.
The call counter alone isn't enough: a method called 1,000 times in the first 100 milliseconds of a process's life is very likely part of framework and startup code, not a genuinely hot path, so the runtime also runs a timer that resets every time a new Tier 0 compilation happens; only once that timer expires without further Tier 0 jitting — a signal that the process has left its initial startup burst — does call counting toward promotion actually begin. This two-part policy exists because the two failure modes are both expensive: promoting too eagerly makes background Tier 1 compilation compete with foreground startup work for CPU, which can lose the entire startup win Tier 0 was supposed to buy; promoting too late leaves genuinely hot code running unoptimized for longer than necessary. Methods containing loops are treated more cautiously by default — TieredCompilationQuickJitForLoops controls whether they get quick-jitted at all, because a long-running loop stuck in Tier 0 for its whole lifetime is a real risk, which is exactly the gap On-Stack Replacement closes.
<PropertyGroup>
<TieredCompilation>true</TieredCompilation> <!-- default: true -->
<TieredCompilationQuickJit>true</TieredCompilationQuickJit> <!-- default: true -->
<TieredCompilationQuickJitForLoops>true</TieredCompilationQuickJitForLoops> <!-- default: true -->
</PropertyGroup>What interviewers look for: the two-part trigger (count and a startup-quiescence timer), because reciting only "after N calls" misses why the policy is designed the way it is.
Common mistakes: assuming Tier 1 promotion happens on a fixed number of calls with no regard to process phase, or assuming tiering is a one-time compile-then-done event rather than a background, ongoing replacement.
Q2 What is On-Stack Replacement (OSR), and why does the runtime need it even with tiered compilation already in place?#
Short answer: OSR lets the runtime swap a currently executing method's code for an optimized version mid-flight, at specific points called patchpoints, which solves the case tiered compilation alone can't: a method with no loops gets promoted between calls just fine, but a method that enters one very long-running loop on its very first call would otherwise be stuck running unoptimized Tier 0 code for that entire loop, because it never returns to give the runtime a chance to swap it for Tier 1.
Patchpoints are placed at loop back-edges — the jump that closes a loop iteration — specifically because that choice guarantees no loop can iterate indefinitely without passing through one, and because back-edge placement lets the eventual optimized OSR method still benefit from the JIT's loop optimizations. Each patchpoint carries a counter; once a loop iterates past a threshold, the runtime compiles a specialized "OSR method" for that entry point, transfers the live local state from the original Tier 0 frame into the new frame, and transitions execution into the optimized version without the method ever having returned. You can see this directly in a JIT dump: a hot, long-running loop shows up as Tier1-OSR output, distinct from a method that was simply promoted between calls and shows as plain Tier1, and a method still warming up shows as Instrumented Tier0. Without OSR, the realistic workaround was disabling Quick JIT for methods with loops entirely — accepting slower startup everywhere to avoid the worst case for the few methods with long-running loops — which is exactly the blunt trade-off OSR made unnecessary.
What interviewers look for: understanding OSR as a mid-execution code swap distinct from ordinary tier promotion (which only happens between invocations), and why loop back-edges specifically are the natural patchpoint location.
Follow-up questions:
- Why are patchpoints placed only at IL-stack-empty points?
- What has to happen to a method's live locals during an OSR transition?
Q3 What is Dynamic PGO, and how is it different from the static profile-guided optimization other ahead-of-time compilers use?#
Short answer: Dynamic PGO instruments a method's own Tier 0 execution — recording branch frequencies and, for virtual/interface call sites, which concrete types actually showed up — and feeds that real, in-process profile directly into the Tier 1 compilation of that same method; static PGO, by contrast, profiles a representative training run ahead of time and bakes those frozen probabilities into a binary that then runs the same way for every future execution, regardless of how the actual workload's behavior differs from the training run.
Because Dynamic PGO's data comes from the exact process and workload that's running right now, it captures things a training run can miss entirely — a virtual call site that's monomorphic in this particular deployment even though the interface has a dozen implementations across the codebase, or a branch that's overwhelmingly one-sided under this tenant's actual traffic shape. This data feeds several downstream optimizations directly: guarded devirtualization uses the observed call-site type distribution, block layout uses the branch frequencies to keep hot paths contiguous and push cold paths (including exception handling blocks) out of the instruction cache's way, and inlining heuristics weight candidates by how often they're actually reached. Dynamic PGO has been on by default since .NET 8 — you no longer need TieredPGO=true to get it — and Microsoft's own benchmark suite showed roughly a 15% average throughput improvement across roughly 4,600 tests, with about a quarter of them improving by 20% or more, which is why leaving it enabled is the default recommendation outside of narrow scenarios where the extra Tier 0 instrumentation overhead genuinely matters.
What interviewers look for: the "profiled from this exact run versus baked in ahead of time" distinction specifically, plus knowing it's default-on since .NET 8 — a candidate who still describes it as an opt-in preview feature is working from an outdated mental model.
Q4 What is ReadyToRun, and doesn't shipping precompiled code defeat the purpose of the JIT's optimizations?#
Short answer: ReadyToRun (R2R) is ahead-of-time-compiled native code embedded alongside IL in the same assembly, used purely to reduce first-use latency; it doesn't defeat JIT optimization because R2R code simply becomes the method's Tier 0 body — tiered compilation still replaces a hot R2R method with a fully JIT-optimized Tier 1 version once it proves itself, exactly as it would replace Quick JIT output.
R2R trades file size for startup speed: an R2R-published assembly runs two to three times larger on disk because it carries both the IL (still needed for generic instantiations the ahead-of-time compiler couldn't predict, reflection, and Tier 1 recompilation) and the precompiled native code, but the runtime skips JIT compilation entirely for every method whose R2R body it can use as-is. The payoff scales with how much code a process actually runs during startup: a large ASP.NET Core app with thousands of methods touched during the first request sees a large win, while a tiny console tool that touches a handful of methods gains little, because the BCL itself already ships R2R-compiled. Composite ReadyToRun (self-contained deployments) goes further, compiling a whole layered set of assemblies together for better cross-assembly optimization, at the cost of much slower publish times and a larger output — the right choice mainly for scenarios like Linux self-contained deployment chasing the fastest possible cold start, or apps that disable tiered compilation outright and want the R2R code to be the final, fully-optimized code.
What interviewers look for: clarity that R2R only replaces Tier 0, not the entire compilation pipeline — it's a startup optimization layered under tiering, not a competing alternative to it.
Common mistakes: believing R2R code never gets replaced, or that publishing with PublishReadyToRun disables further JIT activity for that assembly.
Q5 How does the JIT decide whether to inline a call? What blocks an inline that looks obviously beneficial?#
Short answer: Inlining decisions run through three separate gates — legality (would inlining change program semantics, e.g. across a version boundary the runtime must preserve), ability (can the JIT's machinery actually splice this particular callee's IL into the caller, given its own implementation limits) and profitability (is it actually worth the code-size and compile-time cost) — and a candidate can fail any one of the three even when the other two are satisfied.
Ability limits are the ones that surprise engineers most: a callee that mutates one of its own parameters, contains complex exception handling, or is simply too large in IL bytes can be rejected purely because the JIT's inliner isn't equipped to splice that shape of code, independent of whether inlining it would help. Profitability is a heuristic scoring problem weighing estimated code-size growth against estimated speed gain, informed by Dynamic PGO's call-frequency data when it's available — a rarely-executed call site has to clear a much higher bar than a hot one, because the code-size cost is paid regardless of how often the inlined path actually runs. [MethodImpl(MethodImplOptions.AggressiveInlining)] only raises the profitability bar's tolerance — it's a strong hint, not a command, and the JIT can and does still refuse to inline a method carrying that attribute if legality or ability blocks it (recursive methods, for instance, are never inlined regardless of the attribute). Version-bubble rules add a legality wrinkle specific to ahead-of-time and ReadyToRun compilation: a method can normally only be inlined across assembly boundaries within the same "version bubble" — the set of assemblies serviced/updated together — unless it's marked non-versionable, because inlining across a boundary that can be independently updated would silently bake in behavior that a later servicing update should have changed.
What interviewers look for: the three-way legality/ability/profitability framing, and specifically that AggressiveInlining is a hint that can still be refused — a very common point of overconfidence among mid-level candidates.
Q6 What is guarded devirtualization, and why does it depend on profile data to be effective?#
Short answer: Guarded devirtualization (GDV) rewrites a virtual or interface call site into a cheap type check followed by a direct, inlinable call to the likely concrete implementation, falling back to the normal dispatch path only if the check fails; it needs profile data because the JIT has no static way to know which of the interface's many implementers actually shows up at a given call site without having observed it.
Without a likely-type hint, the JIT can only devirtualize statically in narrow cases — a sealed class, or a call through a variable whose exact type is provably known at compile time — because in general it has no basis for guessing which implementation is common enough to bet on. Dynamic PGO changes that by recording, at Tier 0, which concrete MethodTable actually showed up at a given virtual/interface call site; if one type dominates, Tier 1 compilation can emit if (obj.GetType() == LikelyType) { call LikelyType.Method() directly } else { fall back to normal virtual/interface dispatch }. The win is twofold: the direct call becomes a candidate for ordinary inlining (something a genuine virtual/interface call site never is, because the JIT doesn't know the target ahead of time), and even when it doesn't inline, a correctly predicted direct call is cheaper than walking through virtual stub dispatch's lookup/dispatch/resolve stub chain described in CLR Architecture Interview Questions. GDV degrades gracefully at genuinely polymorphic call sites — the guard simply fails more often, falling through to the same dispatch path that would have run anyway — so it's a pure upside optimization that only activates where the observed profile actually supports it.
What interviewers look for: the causal chain — no profile means no reliable "likely type," and no likely type means no safe basis for the guard — tying GDV directly back to Dynamic PGO rather than presenting it as an independent, always-available optimization.
Follow-up questions:
- What happens to a GDV guard's fallback path at a call site the JIT predicted wrong?
- Why can't the JIT devirtualize a
sealedoverride the same way it devirtualizes based on profile data?
Q7 How does the JIT eliminate array bounds checks, and what coding patterns help or hurt its ability to do so?#
Short answer: The JIT proves a bounds check is redundant when it can statically show an index is always within [0, array.Length) along every path that reaches it — the textbook case is a for loop whose bound is the array's own .Length property, where the JIT can track that the loop variable never exceeds it — and it keeps the check whenever the bound comes from a value it can't relate back to the array's actual length.
This is directly observable in generated assembly: a loop written as for (var i = 0; i < values.Length; i++) sum += values[i]; compiles with the per-element range check fully removed from the loop body, because the JIT can see the comparison against values.Length controlling the loop. Change the loop bound to an unrelated parameter — for (var i = 0; i < count; i++) sum += values[i]; where count isn't provably tied to values.Length — and the JIT keeps a check on every iteration, because for all it knows count could exceed the array's real length. A third pattern splits the difference deliberately: validating the range once up front with values.AsSpan(0, count) (which throws immediately if count is out of range) lets the JIT eliminate the per-iteration check on the resulting span entirely, because the single upfront check is now the only place bounds can be violated — this is a genuinely useful, idiomatic way to get elimination on a bound the JIT can't infer on its own. Devirtualization interacts here too: a bounds check the JIT can't eliminate still compiles to a cheap compare-and-branch, not a function call, so the real cost of "extra" checks is usually smaller than engineers assume, and micro-optimizing code purely to chase bounds-check elimination is rarely worth it outside genuinely hot numeric loops.
// Bound tied to the array's own Length: check eliminated inside the loop.
for (var i = 0; i < values.Length; i++) sum += values[i];
// Bound unrelated to Length: JIT keeps a per-iteration check, it can't prove safety.
for (var i = 0; i < count; i++) sum += values[i];
// One upfront check via a validated Span, then the loop body is check-free.
ReadOnlySpan<int> span = values.AsSpan(0, count);
for (var i = 0; i < span.Length; i++) sum += span[i];What interviewers look for: a concrete, correct mental model of when elimination fires (bound provably tied to the array's length) rather than a vague "the JIT is smart about arrays," plus the practical AsSpan pattern as evidence of applied knowledge.
Q8 What are the real trade-offs of Native AOT versus ReadyToRun versus a normal framework-dependent, JIT-only deployment?#
Short answer: A framework-dependent JIT deployment is the most flexible and the slowest to reach full speed (nothing is precompiled, tiering has to warm everything up); ReadyToRun keeps the JIT and full runtime dynamism but front-loads first-use latency into ahead-of-time-compiled Tier 0 code; Native AOT removes the JIT and most runtime dynamism entirely in exchange for the fastest possible cold start and the smallest memory footprint, at the cost of features that fundamentally require generating code or loading arbitrary assemblies at runtime.
Native AOT's restrictions aren't incidental — they follow directly from compiling the application's entire reachable closure ahead of time with the ilc compiler and statically linking a minimal runtime: there's no Reflection.Emit, no runtime Assembly.LoadFrom of code the compiler didn't already see and compile, and no C++/CLI interop, so plugin architectures, unconstrained runtime reflection over unreferenced types, and dynamic proxy generation are all out unless the framework you're using has been specifically adapted (source generators instead of runtime reflection, for example) to work without them. Trimming is not optional for Native AOT the way it's optional for a self-contained JIT deployment — the whole model depends on knowing the reachable code graph up front, so any genuinely dynamic code path the trimmer can't see risks being cut, which means Native AOT projects need trimming-aware libraries and, often, explicit DynamicDependency/UnconditionalSuppressMessage annotations for edge cases. In exchange, a Native AOT executable typically starts in single-digit milliseconds with a memory footprint well below even an R2R-published equivalent, has no just-in-time compilation pauses at all (which also means no OSR, no Dynamic PGO recompilation, and permanently "Tier 1-equivalent" performance from the first call), and ships as one self-contained native binary with no shared runtime dependency — which is precisely the profile that makes it the right default for CLI tools, sidecars and scale-to-zero serverless functions, and the wrong choice for a plugin host or anything leaning on heavy runtime reflection.
What interviewers look for: treating this as a genuine trade-off along a startup-latency/flexibility axis rather than "Native AOT is strictly better/worse" — and specifically naming why the restrictions exist (ahead-of-time closure compilation) rather than listing them as arbitrary limitations.
Q9 You're optimizing cold-start latency for a service that scales from zero, or a function app that sees tens of thousands of cold starts a day. What's your actual strategy?#
Short answer: Attack the problem in layers, cheapest first: publish with PublishReadyToRun (or move fully to Native AOT if the workload's dynamic-code requirements allow it), keep TieredCompilation and Quick JIT on so whatever isn't precompiled still starts fast, and then profile the actual startup path to remove the specific expensive work — heavy DI container reflection, eager configuration binding, or synchronous first-request JITting of a huge dependency graph — that generic runtime settings can't fix for you.
The runtime-level levers matter but have diminishing returns compared to application-level ones: PublishReadyToRun removes the JIT tax for most framework and app code on first use, and for services where you control the exact target platform (a specific container base image, a specific Azure Functions SKU), Native AOT removes it entirely — the trade being the dynamic-feature restrictions from the previous answer, which is why it's rarely a drop-in switch for an existing large ASP.NET Core app without auditing for reflection-heavy middleware, JSON serialization contexts, and third-party libraries that assume JIT availability. Below the compilation layer, the biggest wins are usually application-shaped: minimizing what runs during static constructors and DI container building (both execute synchronously before the first request can be served), using source-generated JSON serialization (System.Text.Json source generators) instead of reflection-based serialization so the JIT and trimmer both have less dynamic surface to deal with, and, for genuinely bursty serverless workloads, evaluating whether a "snapshot and restore" style warm-start mechanism the hosting platform offers is available, since it sidesteps cold JIT and cold DI entirely rather than just making them faster. The honest, senior-level framing for an interviewer: there is no single knob — it's precompilation to avoid JIT cost, trimming/AOT to avoid it entirely where features allow, and application-level profiling to fix the parts that compilation strategy can't touch.
What interviewers look for: a layered strategy that distinguishes "runtime compilation setting" fixes from "application startup code" fixes, and realistic awareness that Native AOT migration is an audit, not a flag flip, for an existing codebase.
Q10 Clarify what "the JIT" means versus a "tier" versus "RyuJit" — and explain how a hot loop can be mid-execution on OSR'd Tier 1 code while an unrelated cold method elsewhere in the same process has never left Tier 0.#
Short answer: RyuJit is the actual just-in-time compiler component; "the JIT" loosely refers to that compiler plus the whole tiering policy around it; a "tier" is a label for which quality of native code a specific method's slot currently points at, and different methods in the same running process can sit at completely different tiers simultaneously, because tiering is a per-method, demand-driven decision, not a whole-process phase.
This is why a real diagnostic trace can show, at the same instant, one method logged as Instrumented Tier0 (still warming up, gathering Dynamic PGO data), a second logged as Tier1 (already promoted after clearing the call-count-and-timer bar), and a third logged as Tier1-OSR (a long-running loop that got swapped for optimized code mid-execution via a patchpoint, without ever returning) — three different compilation states coexisting because each method's promotion is decided independently, based on its own observed call pattern, not on a global "the process is now optimized" switch. A cold method sitting untouched at Tier 0 forever is completely normal and by design: if it's called rarely enough to never cross the promotion threshold, paying Tier 1's compile-time cost for it would be pure waste, since Quick JIT output for a rarely-run method is already "fast enough" in the aggregate. The unifying mental model an interviewer wants: RyuJit is the tool that generates native code at every tier; tiered compilation is the policy deciding, per method and independently, when to call RyuJit again with a higher optimization target; and OSR is the one mechanism that can trigger that recompilation within a single still-running invocation instead of only between invocations.
What interviewers look for: whether you can hold the per-method, asynchronous nature of tiering in your head accurately enough to explain a mixed-tier snapshot of a running process — this question filters out candidates who've memorized definitions but never actually looked at a JIT trace.
Quick-Fire Round#
| Question | Answer |
|---|---|
| Roughly how many calls before a Tier 0 method is eligible for Tier 1? | About 30, plus a startup-quiescence timer. |
| What mechanism replaces a method's code while it's still executing? | On-Stack Replacement (OSR), at loop-back-edge patchpoints. |
| Since which .NET version is Dynamic PGO enabled by default? | .NET 8. |
| What does ReadyToRun code become inside the tiering system? | The method's Tier 0 body. |
Does [AggressiveInlining] force the JIT to inline? | No — it's a strong hint the JIT can still refuse. |
| What does guarded devirtualization need to be effective? | Profile data identifying a likely concrete type at the call site. |
| What loop-bound pattern lets the JIT eliminate a per-iteration bounds check? | A bound provably tied to the array/span's own length. |
| What compiler produces Native AOT's native code? | ilc, ahead of time, at publish. |
Does Native AOT support unrestricted Reflection.Emit? | No. |
| What JIT compiler generates native code at every tier? | RyuJit. |
How to Prepare#
- Be able to state the two-part Tier 0 → Tier 1 promotion trigger precisely, not just "after enough calls."
- Practice explaining OSR as a mid-execution code swap, distinct from ordinary between-call tier promotion.
- Know Dynamic PGO's default-on status since .NET 8 and roughly what throughput gain Microsoft measured from it.
- Rehearse the legality/ability/profitability framework for inlining with one concrete example that fails each gate.
- Prepare the
for (i < arr.Length)versus unrelated-bound bounds-check example — it's the single most common live-coding JIT question. - Have a layered cold-start strategy ready (precompilation, then AOT/trimming, then application-level profiling) rather than a single silver-bullet setting.