📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does reflection work internally in .NET, why is it slow, and what are the faster alternatives?

RuntimeType and member caches, Invoke costs and the .NET 7/8 invoker improvements, delegates, compiled expressions, UnsafeAccessor and source generators.

Senior Theory Advanced performance reflection runtime-internals source-generators unsafeaccessor

🎯 Key points

  • Reflection reads metadata via RuntimeType, which lazily builds and caches member objects
  • Lookups by name allocate; cache MemberInfo, invokers or delegates
  • Invoke validates arguments, boxes into object[] and wraps exceptions in TargetInvocationException
  • Since .NET 7 repeated Invoke uses emitted invokers; .NET 8 adds MethodInvoker and ConstructorInvoker
  • CreateDelegate gives near-direct calls after a one-time cost
  • UnsafeAccessor (.NET 8) reaches private members without reflection and works with AOT
  • Source generators remove run-time reflection entirely

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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).

1. How reflection works internally

  • 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)):

    1. validate the target object's type and argument count/types,
    2. box value-type arguments into an object[] (allocated by the caller),
    3. convert/unbox arguments for the call,
    4. call through an invocation stub,
    5. box the return value,
    6. wrap exceptions in 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.

2. Why it's slow (summary)

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).

3. Faster alternatives

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.

4. Choosing

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

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. RuntimeTypeCache

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.

2. Invocation stubs evolution

  • .NET Framework / early Core: interpreted invocation via RuntimeMethodHandle.InvokeMethod with significant overhead.
  • .NET 7: emit-based invocation stubs created after a threshold of calls.
  • .NET 8: MethodInvoker/ConstructorInvoker public APIs; Invoke overloads with spans to avoid arrays; improved fast paths.
  • NativeAOT: precomputed invoke stubs for reflectable methods.

3. Delegate creation details

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).

4. Attribute caching

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.

5. Reflection and trimming

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.

Try it yourself

  1. Benchmark direct call, MethodInfo.Invoke, cached delegate, MethodInvoker, UnsafeAccessor.
  2. Call type.GetMethods() twice and compare array references.
  3. Implement a generic static cache of property getters.
  4. Use UnsafeAccessor to read a private field in a test.

💻 Code

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;
}

⚠ Common mistakes

1. Reflection lookups per call

What happens: GetProperty inside loops. Why it is wrong: repeated lookup costs and allocations. Do instead: cache members.

2. Invoke in hot paths

What happens: MethodInfo.Invoke per item. Why it is wrong: allocations, boxing, validation. Do instead: delegates or MethodInvoker.

3. Using expression compilation in AOT

What happens: compiled accessors under NativeAOT. Why it is wrong: interpreted, slow. Do instead: UnsafeAccessor/source generators.

4. Reading attributes repeatedly

What happens: GetCustomAttributes per request. Why it is wrong: constructs attribute objects each time. Do instead: cache or IsDefined.

5. Swallowing TargetInvocationException

What happens: logging only the wrapper. Why it is wrong: hides the real exception. Do instead: unwrap or use BindingFlags.DoNotWrapExceptions.

6. Using reflection when the framework has the data

What happens: reflecting over entities for values EF Core already tracks. Why it is wrong: duplicate work. Do instead: use framework APIs (ChangeTracker entries).

7. UnsafeAccessor to bypass design everywhere

What 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.

8. Ignoring trim warnings for reflection

What happens: reflection over unannotated types in trimmed apps. Why it is wrong: members removed at publish. Do instead: annotate or use generators.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes everyday reflection APIs Why: Checks practical reflection usage
    How to answer

    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.

  2. When: Candidate recommends source generators Why: Probes how generators replace reflection
    How to answer

    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.

  3. When: Candidate says reflection breaks trimming Why: Checks trim-safety techniques
    How to answer

    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>.

🛠 Real-world problems: how you handle them

Reflection-based mapper consumed 40% of an export endpoint's CPU
Situation
A CSV export endpoint mapped [hundreds of thousands] of entities to DTOs using a home-grown reflection mapper.
Signals
Profiler showed `PropertyInfo.GetValue` and `SetValue` with boxing as the top frames; exports took [tens of seconds].
Investigation
Confirmed with a BenchmarkDotNet comparison of reflection mapping versus hand-written mapping.
Resolution
Wrote a small incremental source generator that emits mapping methods at build time and removed the reflection mapper.
Prevention
Banned reflection on request paths in the coding guidelines and added analyser checks for `GetProperties` in hot namespaces.

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.

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me