RuntimeType and member caches, Invoke costs and the .NET 7/8 invoker improvements, delegates, compiled expressions, UnsafeAccessor and source generators.
Reflection reads type metadata through RuntimeType, which builds and caches MethodInfo, PropertyInfo and other member objects on first use. Lookups by name (GetMethod("X")) search and allocate; Invoke must validate arguments, box value types into an object[] and unwrap exceptions into TargetInvocationException. Since .NET 7, repeated Invoke calls switch to emitted IL invokers, and .NET 8 added MethodInvoker and ConstructorInvoker, but direct calls remain far faster. Alternatives: cache MemberInfo, create typed delegates with CreateDelegate, use [UnsafeAccessor] (.NET 8) for private members, or replace reflection with source generators, which also makes code trim and AOT friendly.
Reflection exposes the runtime's type information (MethodTables, metadata) through objects like Type, MethodInfo and PropertyInfo. It's slow because each operation goes through generic, late-bound machinery: name lookups in metadata, object allocations, argument arrays, boxing, validation and security checks, and indirect invocation. The faster alternatives move work from every call to once (cached delegates, MethodInvoker) or to compile time (UnsafeAccessor, source generators).
Type objects for runtime types are RuntimeType instances wrapping a TypeHandle (pointer to the MethodTable).
Member lookup (GetMethod("Submit"), GetProperties()) reads metadata (MethodDef/Field/Property tables), creates RuntimeMethodInfo/RuntimePropertyInfo objects and caches them per type in a reflection cache (RuntimeTypeCache). Lookups still involve string comparison and filtering by BindingFlags; GetMethods() returns a new array each call.
Attributes (GetCustomAttributes) deserialise attribute blobs and construct new attribute objects each call.
Invocation (MethodInfo.Invoke(obj, args)):
object[] (allocated by the caller),TargetInvocationException.Since .NET 7/8, the runtime emits an IL stub per method after a few calls (via DynamicMethod), making repeated Invoke much faster than older versions — but still with allocations and validation overhead.
Field/property access: PropertyInfo.GetValue calls the getter via invocation; FieldInfo.GetValue uses runtime helpers with boxing.
| Cost | Source |
|---|---|
| Lookups | Metadata scans, string comparisons, filtering, array allocations |
| Allocations | object[] args, boxing args/return, MemberInfo arrays, attribute instances |
| Validation | Type checks, parameter conversions per call |
| Indirection | No inlining, generic invocation stubs |
| Exceptions | Wrapping in TargetInvocationException |
Typical numbers: direct call ~1 ns; cached delegate ~1–2 ns; MethodInvoker tens of ns; MethodInfo.Invoke ~20–100+ ns plus allocations (varies by version and signature).
Cache metadata: store PropertyInfo/MethodInfo in static fields or generic static caches (static class Accessors<T>).
Create delegates once:
static readonly Func<Order, decimal> GetTotal =
(Func<Order, decimal>)Delegate.CreateDelegate(typeof(Func<Order, decimal>), typeof(Order).GetProperty("Total")!.GetGetMethod()!);
var total = GetTotal(order); // near-direct speed
MethodInvoker / ConstructorInvoker (.NET 8):
static readonly MethodInvoker Submit = MethodInvoker.Create(typeof(Order).GetMethod("Submit")!);
Submit.Invoke(order); // overloads for 0-4 args avoid object[] allocation
Compiled expression trees (when JIT is available): build Func<object, object> accessors once. Not AOT-friendly (interpreted under NativeAOT).
UnsafeAccessor (.NET 8) for private members, resolved at JIT/AOT compile time:
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_status")]
static extern ref OrderStatus StatusField(Order order);
ref var status = ref StatusField(order); // direct field access
No reflection at run time, trim/AOT-safe, essentially direct speed — used by System.Text.Json source generation and test code.
Source generators: generate the code that reflection would have discovered (serialisation, mapping, DI registration, logging). The fastest and AOT-friendly option.
Generic static caches instead of Dictionary<Type, ...> for per-type metadata.
| Need | Choice |
|---|---|
| One-off reflection at start-up | Plain reflection is fine |
| Repeated calls to known members | Cached delegates / MethodInvoker |
| Private member access (serialisers, tests) | UnsafeAccessor |
| Whole-type operations (serialisation, mapping) | Source generators |
| NativeAOT / trimming | Source generators, UnsafeAccessor, annotated reflection |
A generic audit interceptor captured "before/after" values of entity properties using PropertyInfo.GetValue for every property of every changed entity on SaveChanges — tens of thousands of reflection calls per request on bulk operations, with heavy boxing. Profiling showed 25% of CPU in reflection. Building per-type cached getter delegates (generic static cache) on first use cut that to ~2%; later we switched to EF Core's own ChangeTracker entries (OriginalValues/CurrentValues), which already had the data without reflection.
The interviewer checks you know why reflection is slow and how to avoid it in modern .NET.
Green flags: member caching, Invoke costs (boxing, arrays, wrapping), .NET 7/8 invoker improvements, CreateDelegate, UnsafeAccessor, source generators, AOT implications.
Red flags: uses reflection per request without caching; unaware of TargetInvocationException; only knows expression trees as an alternative.
Each RuntimeType lazily builds caches of members by kind (methods, properties, fields, constructors, events, interfaces, nested types). GetMethods() filters the cache and copies into a new array (to protect the cache from mutation). GetMethod(name) uses name-filtered lookups. Repeated lookups are cheaper than the first but still not free.
RuntimeMethodHandle.InvokeMethod with significant overhead.MethodInvoker/ConstructorInvoker public APIs; Invoke overloads with spans to avoid arrays; improved fast paths.Delegate.CreateDelegate binds directly to the method's entry point; CreateDelegate on an open instance method (first parameter is the target) avoids closures. For properties, bind to getter/setter methods. For fields, use expression trees, UnsafeAccessor, or DynamicMethod (JIT only).
Attribute retrieval constructs new objects each time; IsDefined avoids construction; GetCustomAttributesData describes without constructing. Frameworks (ASP.NET Core endpoint metadata) cache attributes at start-up.
Reflection over members not statically referenced may fail in trimmed/AOT apps. Annotate with [DynamicallyAccessedMembers] or use statically analysable patterns. UnsafeAccessor and source generators are inherently trim-safe.
MethodInfo.Invoke, cached delegate, MethodInvoker, UnsafeAccessor.type.GetMethods() twice and compare array references.UnsafeAccessor to read a private field in a test.using System.Reflection;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<ReflectionBench>();
public sealed class Account
{
private decimal _balance = 100m;
public decimal Add(decimal amount) => _balance += amount;
}
public static class AccountAccessors
{
// .NET 8+: bound by the runtime to the private field, no reflection at call time
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_balance")]
public static extern ref decimal Balance(Account account);
}
[MemoryDiagnoser]
public class ReflectionBench
{
private readonly Account _account = new();
private static readonly MethodInfo AddMethod = typeof(Account).GetMethod(nameof(Account.Add))!;
private static readonly MethodInvoker AddInvoker = MethodInvoker.Create(AddMethod);
private static readonly Func<Account, decimal, decimal> AddDelegate =
AddMethod.CreateDelegate<Func<Account, decimal, decimal>>();
[Benchmark(Baseline = true)] public decimal Direct() => _account.Add(1m);
[Benchmark] public object? Invoke() => AddMethod.Invoke(_account, [1m]); // object[] + boxing
[Benchmark] public object? Invoker() => AddInvoker.Invoke(_account, 1m); // .NET 8 fast path
[Benchmark] public decimal Delegate() => AddDelegate(_account, 1m);
[Benchmark] public decimal Accessor() => AccountAccessors.Balance(_account) += 1m;
}
What happens: GetProperty inside loops.
Why it is wrong: repeated lookup costs and allocations.
Do instead: cache members.
Invoke in hot pathsWhat happens: MethodInfo.Invoke per item.
Why it is wrong: allocations, boxing, validation.
Do instead: delegates or MethodInvoker.
What happens: compiled accessors under NativeAOT.
Why it is wrong: interpreted, slow.
Do instead: UnsafeAccessor/source generators.
What happens: GetCustomAttributes per request.
Why it is wrong: constructs attribute objects each time.
Do instead: cache or IsDefined.
TargetInvocationExceptionWhat happens: logging only the wrapper.
Why it is wrong: hides the real exception.
Do instead: unwrap or use BindingFlags.DoNotWrapExceptions.
What happens: reflecting over entities for values EF Core already tracks. Why it is wrong: duplicate work. Do instead: use framework APIs (ChangeTracker entries).
UnsafeAccessor to bypass design everywhereWhat happens: accessing private members of other teams' types routinely. Why it is wrong: brittle across versions; breaks encapsulation. Do instead: request proper APIs; reserve for serialisers/tests.
What happens: reflection over unannotated types in trimmed apps. Why it is wrong: members removed at publish. Do instead: annotate or use generators.
Reflection is a tool for the edges of a system (startup, plugins, tooling), not the request path.
| Where | Reflection acceptable? |
|---|---|
| Startup discovery, run once | Yes |
| Per request or per row | No; generate or cache delegates |
| Trimmed or NativeAOT app | Only annotated, statically analysable reflection |
| Tests accessing internals | [UnsafeAccessor] or InternalsVisibleTo |
The senior move is to push reflection to build time with source generators, which improves speed, startup and AOT compatibility at once.
Reflection reads the metadata the compiler wrote into an assembly at run time: typeof(T) or obj.GetType() gives a Type, and from it you get PropertyInfo, MethodInfo, FieldInfo and so on, filtered by BindingFlags. It is flexible but slow on hot paths: lookups search metadata, Invoke takes an object[], boxes value types, checks arguments and wraps exceptions in TargetInvocationException. Make it fast by looking up once and caching, turning a MethodInfo into a typed delegate, using MethodInvoker (.NET 8) when you only have a MethodInfo, or [UnsafeAccessor] (.NET 8) to reach a private member with no reflection at all. Reflection also hides dependencies from the trimmer, so in trimmed or Native AOT apps you must annotate with [DynamicallyAccessedMembers] or move the work to a source generator.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
Trimming (<PublishTrimmed>true</PublishTrimmed>, self-contained only, and always on for NativeAOT) runs the ILLink trimmer at publish to remove types and members that static analysis proves are unreachable, shrinking the app. Reflection is its enemy: code that finds types or members by string at run time may lose them. The trimmer reports this with warnings such as IL2026 (RequiresUnreferencedCode) and IL2070-series (missing DynamicallyAccessedMembers). You make code trim-safe by using source generators instead of reflection, annotating Type parameters with [DynamicallyAccessedMembers], and marking unavoidable reflection with [RequiresUnreferencedCode]. Libraries declare <IsTrimmable>true</IsTrimmable>.
How to tell it (STAR)
Our [export endpoint] spent [about 40%] of CPU in reflection mapping. I replaced it with source-generated mappers, which made exports [several times] faster and made the service ready for trimming.
Last updated 31 Oct 2026.