C# Reflection — 50x Throughput Drop from Invoke() Boxing
P99 latency jumped 2ms→120ms, CPU 30%→85% from MethodInfo.Invoke boxing value types.
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Reflection is a runtime API over CLR metadata tables embedded in the PE file
- System.Type is cached after first load; GetMembers() allocates arrays each call
- MethodInfo.Invoke() with value types boxes arguments — adds ~67ns and 96B per call
- Compiled Expression delegates eliminate all reflection overhead ( ~1.1ns, 0 alloc)
- Production killer: uncalled GetMethod() loops cause GC pressure; always cache MemberInfo
- AOT trimming silently removes metadata — annotate with [DynamicallyAccessedMembers] to survive trim
Imagine your app is a locked filing cabinet. Normally you open specific drawers you already know about. Reflection is like getting an X-ray machine — you can see every drawer, every folder, and every sheet of paper inside, even the ones you didn't know existed, and you can read or change them at runtime without a key. It's the CLR letting your code inspect and manipulate itself.
Most C# code is written with full knowledge of the types it works with — you reference a class, call its methods, and the compiler keeps everything honest. But entire categories of powerful software — dependency injection containers, ORMs, serialisers, test frameworks, and plugin systems — work without knowing the types in advance. They discover, inspect, and invoke code at runtime. That capability is Reflection, and it's one of the most consequential APIs in the entire .NET ecosystem.
The problem Reflection solves is compile-time ignorance. When you're building a framework that loads user-supplied assemblies, or a serialiser that must handle any POCO ever written, you can't hard-code type knowledge. Reflection gives you a runtime mirror of the CLR's metadata — every assembly, every type, every method, field, property, and attribute — as a navigable object graph you can query and act on.
By the end of this article you'll understand exactly how the CLR stores and exposes metadata, how to walk the full reflection object model, how to invoke code dynamically with and without caching, how to write and read custom attributes, how to emit IL at runtime with lightweight techniques, where Reflection genuinely kills performance and how to fix it, and the production patterns that make Reflection safe to ship. This is the article you wish existed the first time Reflection bit you in production.
Why C# Reflection Boxing Costs You 50x Throughput
Reflection in C# lets you inspect and invoke types, methods, and properties at runtime using metadata. The core mechanic is that every value type (struct, int, bool, etc.) passed to or returned from a reflected call via MethodInfo.Invoke() gets boxed into an object allocation on the heap. This boxing is not free — it adds allocation pressure, GC pauses, and a 50x throughput drop compared to a direct call. The Invoke() method itself is a virtual call through the runtime's dispatch machinery, but the boxing overhead dominates in tight loops. For a simple method like int Add(int a, int b), a direct call completes in ~2 ns; the same call via Invoke() with boxing can take 100+ ns. That's not a 2x or 5x slowdown — it's 50x. The runtime cannot elide these allocations because it must treat all arguments as object. This is not a micro-optimization; it's a structural cost that kills performance in high-throughput paths like serialization, ORM mapping, or dynamic dispatch frameworks. Use reflection for metadata inspection and late binding, but never for hot-path invocation of value-type-heavy code. When you must invoke dynamically, switch to compiled delegates (Delegate.CreateDelegate) or expression trees (Expression
Invoke() still pays a dynamic dispatch overhead and a null check per call. But boxing adds allocation and GC — the real killer in production.Invoke() on value-type arguments in any path that executes more than 100 times per second.Invoke() on value types causes boxing — a 50x throughput drop vs direct call.How the CLR Metadata System Actually Works Under the Hood
Every .NET assembly is a PE (Portable Executable) file. Alongside the IL bytecode, the compiler embeds a rich metadata section — tables of type definitions, method signatures, field layouts, custom attributes, and inter-assembly references. This is the same data Visual Studio uses for IntelliSense. Reflection is simply a managed API that reads those tables at runtime.
The entry point is always System.Type. It's an abstract class whose concrete implementation, System.RuntimeType, is created and cached by the CLR the first time a type is loaded into an AppDomain. That means Type.GetType() calls after the first one are cheap — you're reading a cached CLR object, not re-parsing the binary.
The hierarchy goes: AppDomain → Assembly → Module → Type → MemberInfo (MethodInfo, FieldInfo, PropertyInfo, ConstructorInfo, EventInfo). Every one of these is an object you can hold a reference to, compare, and pass around. MethodInfo.Invoke() ultimately calls into native CLR code that performs a late-bound dispatch — it finds the compiled JIT stub for the method and calls it, bypassing compile-time binding but still executing fully-compiled IL.
Understanding this model matters because it tells you exactly where costs live: type lookup is cheap after the first load, MemberInfo retrieval has moderate overhead (array allocation), and Invoke itself is expensive — roughly 50-100x slower than a direct call — because of argument boxing, security checks, and the late-bind dispatch mechanism.
public int ProcessedCount { get; private set; }, the CLR generates a backing field named <ProcessedCount>k__BackingField. If you're trying to set it directly via FieldInfo.SetValue() (a common serialiser trick), search for it by that exact pattern — don't assume the field name matches the property name.GetMembers() which allocates a new array every call — always cache MemberInfo arrays.GetMembers() and GetCustomAttributes() allocate every call.Dynamic Invocation, Custom Attributes, and Caching for Production Use
Raw Reflection.Invoke() is the sledgehammer — powerful but slow. In production you have two main upgrade paths: cache MemberInfo objects so you avoid repeated metadata lookups, and use compiled delegates or expression trees to turn a one-time reflection cost into a near-zero per-call cost.
Custom attributes are where Reflection really earns its keep in frameworks. By decorating types and members with attributes you create a declarative metadata layer — think [Required], [HttpGet], or your own [AuditLog]. Reflection reads those attributes at startup to build routing tables, validation rules, or processing pipelines without any hard-coded type knowledge.
The pattern that scales: do your reflection work once at application startup, compile it into Func<> delegates via Expression.Compile() or Delegate.CreateDelegate(), then call those delegates at request time. The delegate call is indistinguishable from a direct call in the JIT — no boxing overhead, no late-bind dispatch.
Below is a realistic mini-framework that discovers all types decorated with a custom [CommandHandler] attribute, builds a dispatch table from command name to handler delegate, and invokes handlers at near-native speed — the same pattern used by MediatR, minimal API routing, and plugin systems.
Delegate.CreateDelegate() is faster to set up than building an Expression tree and compiling it — use it when the method signature is fixed. Use Expression trees when you need to adapt signatures, coerce types, or construct objects. Both produce near-native invocation speed.GetTypes() on a 100-assembly app can take 500ms.Reflection Performance — Benchmarks, Bottlenecks, and the Source Generator Alternative
The performance story of Reflection has two distinct chapters: .NET Framework (slow, always) and modern .NET (much better, but still has sharp edges). On .NET 7+ the JIT can devirtualise some reflection calls and the metadata reader is significantly faster, but MethodInfo.Invoke() still carries boxing overhead for value types and a security stack-walk on first invocation.
Here's what actually costs time, ranked: (1) Assembly scanning with GetTypes() — O(n) where n is total types, can be tens of milliseconds for large assemblies; (2) GetCustomAttributes() — allocates an array on every call if not cached; (3) MethodInfo.Invoke() — approximately 50-100ns per call vs ~1ns for a direct call; (4) Activator.CreateInstance
The modern answer for hot-path scenarios is Source Generators. Introduced in .NET 5, they run at compile time and generate the type-inspection code that Reflection would have run at runtime. System.Text.Json switched from Reflection to Source Generators in .NET 6 and saw 2-3x serialisation throughput improvements. If you're writing a library that does Reflection-heavy work, offering a Source Generator path is now table-stakes for performance-sensitive users.
For scenarios where you can't use Source Generators — runtime plugin loading, for example — the cached-delegate pattern shown earlier is your best tool. The benchmark below makes the cost differences concrete.
Invoke() takes an object[] parameter array. Passing value types like decimal causes boxing — wrapping the value in a heap-allocated object. That's the 96 bytes you see in the benchmark. The compiled delegate avoids this entirely because it's strongly typed — the JIT passes decimals as value types on the stack, just like a direct call.Invoke() with two decimals allocates 96B. At 10K RPS, that's 960KB/s of GC pressure.Invoke() for value types.Reflection.Emit: Generating IL at Runtime for Maximum Flexibility
When compiled delegates aren't enough — think dynamic proxies, mock frameworks, or serialisers that need to create entire methods on the fly — Reflection.Emit gives you direct access to the IL emitter. This is the low-level API that tools like Castle.Core (DynamicProxy) and Moq use under the hood. It's complex, but it's the only way to generate new types and methods at runtime without resorting to file-based code generation.
The core classes are AssemblyBuilder, ModuleBuilder, TypeBuilder, and ILGenerator. You define a dynamic assembly, create a type, add methods, and emit IL opcodes directly. The resulting code is fully JIT-compiled and executes at native speed. The cost is development time and complexity — one misplaced opcode can corrupt the stack or cause runtime execution errors.
In modern .NET, the more practical approach for most scenarios is to use the source generators or the expression trees we've already covered. But if you ever need to implement AOP interceptors, ORM lazy loading proxies, or compile-time serialisers for types discovered at runtime, Reflection.Emit is the tool. Just remember: dynamic assemblies cannot be unloaded unless you use an AssemblyLoadContext.
Production Gotchas — Private Members, Security, AOT Compatibility, and Thread Safety
Reflection in production has a few traps that only reveal themselves at scale or in unusual deployment environments. Let's cover the ones that actually hurt teams.
Private member access works fine in standard .NET but is restricted in Ahead-of-Time (AOT) compiled apps (.NET NativeAOT, Blazor WASM in full AOT mode, and iOS/Android with Xamarin/MAUI). AOT strips metadata for private members by default to reduce binary size. If your serialiser or DI container tries to set a private field, it silently fails or throws. The fix is rd.xml trim directives (for older platforms) or the newer [DynamicallyAccessedMembers] attribute, which tells the trimmer exactly what metadata to preserve.
Thread safety: Type and MemberInfo objects themselves are thread-safe to read — the CLR guarantees that. But if you're building a Dictionary<string, MethodInfo> discovery cache, make sure you use ConcurrentDictionary or initialise it once before any concurrent access. The bug pattern is a lazy-initialised static dictionary populated in a static constructor that gets hit from multiple threads — the static constructor is thread-safe, but populating a regular Dictionary after construction is not.
Generic type reflection adds a wrinkle: typeof(List<>) gives you the open generic type definition. You need Type.MakeGenericType() to get List<string> at runtime. And GetMethod() on a generic type requires you to filter by parameter count and then check IsGenericMethodDefinition — GetMethod by name alone will throw AmbiguousMatchException if there are overloads.
GetProperties() or GetFields() on a type that isn't annotated will return an empty array — no exception, just empty results. Annotate all reflection entry points with [DynamicallyAccessedMembers] and run dotnet publish -r <rid> --self-contained locally to catch trim warnings before they reach production.Loading Assemblies Dynamically Without Tanking Your AppDomain
You don't always know at compile time which assembly you need. Maybe you're building a plugin system or a scripting host. Assembly.LoadFrom and Assembly.LoadFile look identical in a tutorial. In production, they'll silently destroy your type identity. LoadFrom binds to the load context, deduplicates by path, and will throw FileLoadException if the same assembly is loaded twice from different paths. LoadFile loads it fresh every time, breaking is and as checks because the same type has two different runtime identities. The fix? Assembly.Load(byte[]) reads the assembly into memory and returns a single identity. You own the lifetime. For AOT scenarios, this path is dead — you must pre-register types. Always catch ReflectionTypeLoadException when iterating loaded types; that assembly you loaded might have a bad dependency, and the framework will silently swallow the TypeLoadException but still return partial results. Log the LoaderExceptions array or you'll chase ghosts.
Using Reflection with Attributes: Don't Scan Everything Every Time
Custom attributes are metadata. You attach [JsonIgnore] or your own [Authorize] and assume reflection finds them cheaply. Wrong. GetCustomAttributes() triggers a metadata walk that allocates attribute instances. On a hot path — say, deserializing 10,000 JSON objects — that allocation pressure will bury you. The WHY: each call creates a new Attribute[] array and instantiates each attribute class. Solution: cache attribute lookups per type in a ConcurrentDictionary. For single-attribute checks, use IsDefined(typeof(T), inherit: false) — it returns bool without instantiating the attribute. That's a 10x perf win for validation or authorization filters. If you need multiple attributes, cache the result after first access. Never call GetCustomAttributes inside a loop. Source generators can eliminate this entirely, but if you're stuck on reflection, cache aggressively.
Source Generators as Reflection Alternative
Source generators, introduced in C# 9, offer a compile-time alternative to runtime reflection by generating code during compilation. This eliminates boxing overhead and improves throughput. For example, instead of using typeof and Invoke() to call a method dynamically, a source generator can produce a strongly-typed wrapper. Consider a scenario where you need to invoke a method based on a string name. With reflection, you'd write:
``csharp var method = typeof(MyClass).GetMethod("MyMethod"); method.Invoke(instance, new object[] { arg1, arg2 }); ``
This boxes arguments and incurs overhead. A source generator can create a delegate at compile time:
``csharp [GenerateMethodInvoker(typeof(MyClass), "MyMethod")] public static partial void InvokeMyMethod(MyClass instance, int arg1, string arg2); ``
The generated code avoids boxing by using typed parameters and direct calls. Source generators are ideal for scenarios like serialization, dependency injection, and mapping, where reflection is traditionally used. They improve performance and enable AOT compilation by removing runtime metadata dependencies.
Incremental Source Generators in .NET 8+
Incremental source generators, introduced in .NET 6 and refined in .NET 8, improve build performance by caching and reusing generator outputs when inputs haven't changed. Unlike traditional source generators that re-run on every compilation, incremental generators track dependencies and only regenerate when necessary. This is crucial for large projects where reflection-based code generation can slow down builds. For example, an incremental generator for a JSON serializer can cache the generated code for a type until the type's definition changes. The implementation uses IncrementalGenerator and IncrementalValuesProvider to analyze syntax trees and symbols efficiently. Here's a minimal example:
``csharp [Generator] public class MyIncrementalGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var provider = context.SyntaxProvider.CreateSyntaxProvider( predicate: (node, _) => node is ClassDeclarationSyntax, transform: (ctx, _) => (ClassDeclarationSyntax)ctx.Node); context.RegisterSourceOutput(provider, (spc, classDecl) => { // Generate source based on class declaration spc.AddSource($"{classDecl.Identifier.Text}_Generated.cs", "// generated code"); }); } } ``
This approach reduces build times significantly, making it practical to use source generators in large codebases without developer friction.
FunctionPointers and UnmanagedCallersOnly as Native Reflection
In .NET 5+, FunctionPointer types and UnmanagedCallersOnly provide a way to call native functions with minimal overhead, akin to reflection but without boxing. FunctionPointer is a value type that holds a pointer to a function, enabling direct invocation. UnmanagedCallersOnly allows a managed method to be called from native code as if it were a native function. This is useful for interop scenarios where you need to dynamically dispatch calls without the cost of Delegate or reflection. For example, you can use Marshal.GetFunctionPointerForDelegate to get a function pointer and call it via FunctionPointer:
``csharp unsafe { delegate* managed``
This avoids boxing entirely. Combined with UnmanagedCallersOnly, you can expose managed methods to native code with minimal overhead. However, this approach is limited to static methods and requires unsafe code. It's best suited for high-performance interop or plugin systems where reflection is too slow.
The 50x Throughput Drop: Uncached Invoke() in a High-Traffic API
Invoke() also performs a security stack walk on first call per method.- Never use
MethodInfo.Invoke()in a hot path with value type arguments — the boxing cost kills throughput. - Compile delegates at startup using Expression trees or
Delegate.CreateDelegate()to get near-native speed. - Always profile before assuming reflection is 'fast enough'; one
Invoke()in a loop can tank an entire service.
dotnet-trace collect --providers Microsoft-DotNETRuntimeSampledObjectAllocation. Look for allocations in System.Reflection.MethodBaseInvoker. Replace Invoke() with compiled delegates.GetProperties() returns empty array in a NativeAOT published binarydotnet publish -r win-x64 --self-contained and check trim warnings. Add [DynamicallyAccessedMembers] on the type parameter or use [RequiresUnreferencedCode] to signal the trimmer.GetMethod() for an overloaded methodGetMethods().Where(m => m.Name == 'Add' && m.GetParameters().Length == 1) and manually select the correct one.dotnet-trace collect -p <PID> --providers Microsoft-DotNETRuntimeSampledObjectAllocationdotnet-trace report <trace.nettrace> topN --allocInvoke() with Expression.Compile() or Delegate.CreateDelegate().| File | Command / Code | Purpose |
|---|---|---|
| ReflectionMetadataExplorer.cs | using System; | How the CLR Metadata System Actually Works Under the Hood |
| CommandDispatcher.cs | using System; | Dynamic Invocation, Custom Attributes, and Caching for Produ |
| ReflectionPerformanceBenchmark.cs | using System; | Reflection Performance |
| DynamicProxyWithEmit.cs | using System; | Reflection.Emit |
| ProductionReflectionPatterns.cs | using System; | Production Gotchas |
| AssemblyLoader.cs | public static class AssemblyLoader | Loading Assemblies Dynamically Without Tanking Your AppDomai |
| AttributeCache.cs | public static class AttributeCache | Using Reflection with Attributes |
| SourceGeneratorExample.cs | [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] | Source Generators as Reflection Alternative |
| IncrementalGenerator.cs | using Microsoft.CodeAnalysis; | Incremental Source Generators in .NET 8+ |
| FunctionPointerExample.cs | using System; | FunctionPointers and UnmanagedCallersOnly as Native Reflecti |
Key takeaways
typeof() and GetType() are cheap, but GetCustomAttributes() and Invoke() allocate on every call if not handled carefully.MethodInfo.Invoke() for value-type argumentsInterview Questions on This Topic
What's the performance difference between a cached MethodInfo.Invoke() call and a compiled Expression tree delegate, and why does the difference exist at the IL/JIT level?
MethodInfo.Invoke() boxes value-type arguments into object[], allocates ~96B per call, and incurs a security stack walk on first invocation. The JIT cannot inline or devirtualise the call. A compiled Expression delegate, on the other hand, produces a strongly-typed delegate that the JIT can inline and devirtualise — per-call cost drops to ~1.1ns with zero allocations. The difference is roughly 68x for a cached invoke, and over 400x for an uncached one.Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
That's C# Advanced. Mark it forged?
8 min read · try the examples if you haven't