A customer reported that exporting their order history to CSV felt “frozen.” About 50,000 rows, and the request sat there for nearly nine seconds.
The database query was fast. The network was fine. I spent half a day profiling before the answer showed up in a flame graph, in a place I hadn’t thought to look: reflection.
What reflection actually is
Reflection lets your code inspect itself at runtime. You can ask a type what properties it has, read a property’s value by name, or call a method you only know by a string. It’s how serializers, ORMs, dependency injection containers, and mappers work under the hood.
It’s powerful because your code no longer needs to know the types at compile time. It costs something because the runtime has to look up metadata every time you ask.
Real-world example: before
Our exporter was generic. It took any list of objects and a list of column names, and wrote them out. Here’s the simplified version:
public string Export<T>(IEnumerable<T> rows, string[] columns)
{
var sb = new StringBuilder();
foreach (var row in rows)
{
foreach (var column in columns)
{
var prop = row.GetType().GetProperty(column);
var value = prop.GetValue(row);
sb.Append(value).Append(',');
}
sb.AppendLine();
}
return sb.ToString();
}
It looks harmless. But with 50,000 rows and 8 columns, GetProperty ran 400,000 times. Every call asked the runtime to search the type’s metadata for a property it had already found on the previous row.
Real-world example: after
The property doesn’t change between rows. Only the object does. So the lookup belongs outside the loop:
public string Export<T>(IEnumerable<T> rows, string[] columns)
{
var props = columns
.Select(c => typeof(T).GetProperty(c))
.ToArray();
var sb = new StringBuilder();
foreach (var row in rows)
{
foreach (var prop in props)
{
sb.Append(prop.GetValue(row)).Append(',');
}
sb.AppendLine();
}
return sb.ToString();
}
Eight metadata lookups instead of 400,000. The export dropped from about nine seconds to under one.
If you need to go further
PropertyInfo.GetValue still has overhead on every call, because it’s a late-bound invocation. For truly hot paths, you can compile the getter once into a delegate and reuse it:
var param = Expression.Parameter(typeof(T), "x");
var getter = Expression.Lambda<Func<T, object>>(
Expression.Convert(Expression.Property(param, "Name"), typeof(object)),
param).Compile();
var value = getter(row); // close to a normal property read
Compiling costs something up front, so it only pays off when the same property is read many thousands of times. That is exactly why libraries like AutoMapper and many serializers build and cache these delegates internally. In newer .NET versions, source generators take this further by moving the work to compile time, so there is no runtime reflection at all.
When reflection is perfectly fine
Reflection isn’t the villain. Reading metadata once at startup, scanning assemblies to register services, or building a one-off admin tool are all reasonable uses. The problem is reflection inside a hot loop, where a cost you’d never notice once gets multiplied by the size of your data.
The lesson
Reflection works the way a phone book works. Looking up one number is trivial. Looking up the same number 400,000 times is a design problem. If the answer won’t change between iterations, find it once, keep it, and reuse it.
