C# Interceptors: Native AOT & Performance for Free | Stefan Pölz | .NET Day 2026
In a Nutshell
C# interceptors let source generators replace specific method calls at compile time by emitting compatible methods with InterceptsLocation attributes, enabling Native AOT compatibility for Minimal APIs without code changes. The .NET team ships a Request Delegate Generator that intercepts MapGet/MapPost calls, moving reflection and dynamic IL generation from runtime to build time. This delivers Native AOT benefits—fast startup, no JIT overhead, smaller executables—while maintaining the original developer experience.
These notes were generated by AI and may contain inaccuracies.
Stefan Hertz presents on C# interceptors, Native AOT, and free performance. Interceptors were an experimental feature in .NET 8 and have been publicly available since .NET 9. This is not a feature of the C# language itself, but a feature of the compiler that integrates into systems using source code generators.
The session covers: introduction to interceptors, Native AOT technology, source code generators, and how these components link together so a source code generator can issue an interceptor that allows compilation of Native AOT in some scenarios.
Stefan identifies as a contributor to the JetBrains community, Microsoft MVP, and organizer of .NET meetups in Vienna. His focus is on how the compiler works from the inside, how to use it in certain scenarios, and how the .NET team implements features for best possible performance. This session continues from a session given 4 years ago at the "JetBrains .NET developer days" event where he discussed incremental source generators.
Interceptors in their current general availability (GA) version are difficult to explain, so the preview version introduced in .NET 8 is demonstrated instead. Warnings appear because this preview version is outdated and may be removed by the compiler at any time, possibly by the release of dotnet 11. The older version still works and illustrates what interceptors really do.
A simple console application demonstrates two methods: a static method and an instance method, both printing "ordinary method". These are called ordinary methods, while getters, setters, constructors, delegates, and local functions are different types of methods from Roslyn's perspective. The static method receives "Hello World" and the instance method receives "dotnet days online".
An interceptor is manually inserted by adding a compatible signature method. A compatible signature must also be a normal method, cannot be a local function, must have the same return type, and must contain the same number and types of parameters, though names do not need to match. For targeting a static method, this is sufficient. For targeting an instance method, the receiver type is passed as with traditional C# 3 extension methods.
The attribute is not part of the core class library. The GA release has a similar though not identical feature not in the BCL library either, but source generators can release this attribute. Three aspects are specified: file path, line number, and character position. This provides a location in the source code giving the compiler a hint to intercept calls with constant binding.
The interceptor targets a specific call location in program.cs at line 7, character 36. Instead of invoking the original "ordinary method", the intercept method is called which prints "intercepted" and "fixed". Without changing the code below, running produces intercepted strings instead of regular strings.
In the GA version, the feature looks different but works similarly. The attribute uses a release and data series encoding file location, line number, character number, and checksum values. The data string is not human-readable but designed for machine generation by source code generators.
C# files compile via Roslyn into IL code, the intermediate language. IL code is not executable and represents an intermediate form of .NET implementation. Visual Basic files also compile via Roslyn into IL code. F# has its own assembly tools for creating IL code.
On Windows x64, IL code is not bytecode for the device. When running dotnet, RyuJIT (the real-time interpreter) converts IL code into device-specific code. IL code is device-neutral, requiring only the appropriate runtime interpreter for each machine architecture. Mac systems generate macOS-compatible versions, while Linux x64 or ARM machines generate code for those specific architectures.
The advantage is a single codebase working across platforms with appropriate runtime installed. The drawback is JIT compilation time. The first execution of any code requires translating IL to executable code, taking time. RyuJIT generates code quickly initially, then creates faster optimized code for frequently executed paths, swapping code paths transparently.
For long-running applications like ASP.NET Core, JIT is ideal because applications start quickly and performance improves over time. For short-lived applications like console applications running milliseconds, or web applications requiring immediate availability at large scale, JIT overhead may be unacceptable.
AOT (ahead-of-time) compilation generates device-specific code during the build phase using the AOT compiler. The output is machine code specific to target architectures like Win x64 or Linux x64. These executables are device-specific, unlike IL code which is device-neutral. A Win x64 file works only on 64-bit Windows, and Linux files work only on Linux systems.
The advantage is absence of JIT translation with no JIT compiler available, so code executes directly. Some scenarios cannot use JIT at all, such as macOS security directives preventing generation and execution of new code within applications, as this would bypass App Store review. AOT advantages include extremely fast startup time with pre-generated executable code ready for execution. The downside is no JIT compiler available, and some features depend on JIT, such as regular expressions.
Four benchmark tests compare Regex approaches. Creating Regex directly without caching uses interpreted Regex with no Compilation flag, taking consistent time for same inputs. Compiled Regex launches JIT on first run via Reflection Emit to generate new IL code that JIT compiles, creating specialized code for the pattern. First execution is slower but subsequent runs are faster.
Creating new Regex each time demonstrates the initial cost of compiled Regex. Caching the same Regex pays the initial JIT compilation cost once, then uses the faster JIT-created loop. In Native AOT, no JIT interpreter exists, so the interpreted version always results regardless of flags, performing slower than compiled versions.
The .NET team introduced a Source Generator for Regex. A partial type has implementation created by the source generator. Generated code appears in .g.cs files with gray background highlighting in Rider, indicating generated code that should not be edited. This occurs during build time, not runtime via IL translation, producing improved pattern expressions compatible with Native AOT scenarios and performing best in AOT.
Benchmark results show interpreted Regex with new allocation consumes little memory and runs reasonably fast. Creating compiled Regex each time shows the compilation cost at runtime. Reusing compiled Regex pays the JIT cost once with subsequent operations much faster than interpreted versions, becoming allocation-free. The generated version transfers complexity from runtime to build time, incurs no initial JIT cost, remains Native AOT compatible, and performs slightly faster than compiled versions.
A basic ASP.NET Core Native AOT sample uses dotnet new webapi -aot, creating a minimal API with PublishAot enabled. Running dotnet publish creates AOT-compiled executable for 64-bit Windows on Win x64 machines. Runtime ID can specify other targets like Linux, though CI scenarios typically build on target devices.
Two aspects prevent Native AOT from working: MapGet methods have "RequiresUnreferencedCode" attributes indicating reflection usage. GetType, GetMember, or GetField with string names create non-constant bindings occurring at runtime. Native AOT trims code by scanning all code to identify used items, removing unused code from final executables because machine code is larger than optimized IL code.
Trimming removes reflection-based code, causing "member not found" or "type not found" exceptions at runtime. Warnings appear indicating "RequiresUnreferencedCode" and potential runtime failure. "RequiresDynamicCode" refers to dynamically emitted code issuing new IL at runtime, similar to compiled Regex. Minimal APIs use both reflection and new IL emission for created endpoints, incompatible with Native AOT.
Native AOT warnings do not appear because interceptors fix issues behind the scenes. Methods with unreferenced code and dynamic code requirements are not actually called. An interceptor intercepts these invocations, transferring IL creation and code generation complexity from runtime to compile time, invoking alternative methods at compile time. No code changes are needed in Minimal API code. Setting PublishAot enables Native AOT compatibility because the .NET team provides a source generator issuing interceptors.
Code generators appear under Dependencies > Target Framework > Code Generators in Rider. Multiple generators exist including JSON source generator for AOT-compatible and performant JSON, and a program source generator outputting a Partial Program class. The Request Delegate Generator issues the InterceptsLocation attribute in a file-type-dependent way, used only within that file. This tells the compiler to invoke generated methods instead of the original MapGet methods.
Generated methods have compatible signatures, are static, take identical parameters, and may have different names. The InterceptsLocation attribute in the first version encodes the interception location. The data string indicates interception of the callback from program.cs at specific line and character positions. Instead of calling the shared ASP.NET Core library MapGet method, the generated method is called. Multiple MapGet methods generate corresponding interceptors like MapGet0 and MapGet1.
Deleting generated interceptors causes the code generator to regenerate, removing corresponding interceptors. Changing source code location updates the encoded data string automatically as the generator runs.
The generator is a component that runs in your development environment all the time, so Rider understands when it should be run, and then the generated code happens, and in this case, the interceptor as well. Thus, we get Native AOT technology and high performance for free.
No changes were needed to any part of the source code because the .NET team was generous enough to provide an interceptor that makes the microAPIs compatible with Native AOT.
For the last section, what would the code generator that issues an intercept look like? If you would like a more in-depth look at how code generators work, please see my presentation from the 2021/2022 JetBrains .NET Developer Days conference, where I explained in detail how a code generator works.
We see that this is simply an incremental code generator. We want to know what we want to intercept, so we use the formula provider to look at the formula tree. A great feature in Rider.
We can take a look at the syntax visualizer to see what a C# syntax tree looks like. This is how Wasteland sees our program code. We see that we have a set of usage directives. We have a file-level namespace permission in this domain. We have a class declaration, there are fields and methods, and in particular the initialization method that I am looking at here, and so on. This is the whole tree, and this is what Wasteland sees and what the source code generator sees.
So we scan the code for the things we want to intercept. The ASP.NET Core team is looking for these map get and map post methods. If he wants to intercept it, he can request the semantic model to obtain the interceptable location. This objectionable site here contains the information required to create this attribute above the objection method.
When we actually issue the code, we then call get intercepts location attribute syntax. Then we take a look at the implementation here. We'll see what actually happens. This outputs an attribute, which is a location interception attribute with version, in our case it is one, and a data string containing the file path, line, character location, and also some symbols (hash).
I hope I have answered all possible questions about interceptors and native AOT. If I haven't done so, I think this is the right time to answer it.
It was a wonderful session as usual, with a deep dive into some of the hidden magic that takes place behind the scenes there. I didn't know there was so much going on under the hood for web applications there. I didn't realize that the .NET team had written these interceptors, source code generators, etc. But it's a really great feature.
Were objectors added for that use case? Because I always thought that the objectors were a somewhat specialized feature. If my memory serves me right, I believe the first interceptor used by the .NET team was the request delegate generator. Therefore, I can imagine that I am not sure if it was designed specifically for the minimal APIs of native AOT. But I can imagine that it was generally designed to facilitate native AOT scenarios.
Theoretically, you don't need an objector. You can also generate the code at compile time, but then you will need to update the source code manually. So, the Interceptor is a nice user experience, yes, a very nice one. You just need to activate a tag, and it will be done because the translator performs a subtle switching process in the background.
It is amazing that this kind of magic happens behind the scenes. I kind of like it when magic happens without me being aware of it. I always feel anxious if there is magic and I know about it, because then I think it might be disrupted. But, if I didn't know about it and it was simply working, I would say, "Oh, okay." This is how it works, simply put. And I'm happy about that. So, that's good. But, no, I really enjoyed this in-depth exploration. It makes sense to me. This is the only logic I need, to be honest. I understand you. I understand you.
So, what else, aside from performance, are there other reasons why you might use AOT in a general web application or something else? Well, from what I've experienced, there are one of two reasons. Either performance at startup, so that you get a web application ready in a fraction of the time of just-in-time compilation. Especially in console applications.
Currently, for .NET 11, the .NET team is converting parts of the .NET development kit to Native AOT, to be faster because command interfaces are often short-lived. Well, not MCP servers, but regular command interfaces are very short-lived, so they start up quickly and disappear quickly. Therefore, in these scenarios, for the purpose of starting up, or in environments where you cannot use the interpreter such as iOS devices due to a security feature that prevents code generation and execution on the same memory page.
So, in this case, we need Native AOT, and the Maui team is now moving to Core CLR as well, but previously they were using Mono, and that was not Native AOT, it was Mono AOT. There has always been AOT, but now with native AOT, there is a greater incentive, especially in the two cases I have encountered so far.
I am old enough to remember a tool called NGen that we used for a similar purpose, and one of the reasons for abandoning it and switching to this is to be able to compile code into machine language and save those few seconds, especially in web applications and the like in very old versions of .NET. So, this actually solves a lot of those problems.
These days, if you have a web application, you actually have quite a few source generators. You have a source generator for JSON, so you don't have to deal with runtime complexity. Also for settings and verification, so there are many things to remove reflection, not only to gain performance, but also to achieve compatibility with native AOT.
To the best of my knowledge, controller-based APIs do not have similar facilities, so they are not yet compatible with AOT. I am not aware of... I need to check the status of this route. But yes, minimal APIs are...well, if you have some manual reflection code, you need to change it. Those warnings will be clearly visible. However, if you are not using any reflection-based code or a library that is not native AOT-compatible, and if all your dependencies are compatible, you can simply toggle the deployment setting and try it out to see if it works with your specific scenario.
But definitely keep in mind that the Just-in-Time (JIT) interpreter is a real beast, and it improves itself through on-stack replacement and other terms that I can't recall right now. It is a type of self-improving machine. So, there's also tiered compilation, right? Multi-level compilation, yes. All of that. So, as with everything related to performance, measure, measure, and measure some more.
Quickly, before we move on, do you think there are any reasons...would you use AOT for your web application? I just mentioned that it could help speed things up there. If you have a general-purpose web application, is it worth using it with AOT or is that overkill? If it is short-lived, yes. If I were to run it quickly and then terminate it again in a container or in the style of Azure functions, I think so. However, if the program is long-term, and runs for hours or more, I will rely on the Just-in-time translator.
Um, I haven't fully read Stephen Taub's blog post about performance improvements in NET 11. Well, calling it a blog post is an understatement. It's like a book. But yes, there are always improvements to the Just-in-time translator. Therefore, in command-line interface (CLI) applications, I tend to try to make them compatible with Native AOT by default.
Um, we're out of time. Um, so, thank you very much for this really enjoyable session. Again, I love these deep dives into the things that happen behind the scenes. Um, I've shared some links in the chat if you want to see more things like Stefan's recent presentation here, which was about source generators and it's also great. Oh, you can find it in the chat. Meanwhile, thank you very much, Stefan. We hope to see you again soon. Thank you so much for having me, and I'm looking forward to the rest of the day.
Keep JetBrains in your library
Save the videos and channels worth coming back to, and find them again in one place.

