C# Attributes — The Reflection Caching Gap Killing Your RPS
GetCustomAttribute allocates a new instance every call.
- Attributes attach metadata to code elements (classes, methods, properties) — stored in assembly IL, instantiated only when read via reflection
- Key components: Attribute class (inherits System.Attribute), AttributeUsage (targets, allow multiple, inheritance), GetCustomAttribute (reflection reader)
- Performance: GetCustomAttribute costs ~1µs on first call (type lookup + constructor), subsequent calls similar without caching — use static Dictionary cache for hot paths
- Production trap: Calling GetCustomAttribute in a hot loop (e.g., per-request) without caching — adds microseconds per call, becomes milliseconds at scale (1000 req/s = 1ms overhead)
- Biggest mistake: Forgetting Inherited=true in [AttributeUsage] — subclass silently gets the attribute, causing table mapping collisions in EF Core or routing conflicts in ASP.NET
Imagine every piece of luggage at an airport has a tag stuck to it — that tag doesn't change what's inside the bag, but it tells the airline system how to handle it: fragile, first-class, priority loading. C# attributes are exactly that: sticky tags you attach to your classes, methods, or properties that tell the runtime, a framework, or your own code how to treat them. The code itself doesn't change — the tag just adds extra meaning.
Every production .NET codebase is full of attributes — [Serializable], [HttpGet], [Required], [Authorize] — and yet most junior developers treat them like magic spells they copy from Stack Overflow without understanding what's actually happening. That's a problem, because attributes are one of the most powerful tools in C# for writing clean, self-documenting, and extensible code without littering your business logic with repetitive boilerplate.
Attributes solve a specific problem: how do you attach extra information to a piece of code — a method, a class, a property — without changing its logic or signature? Before attributes, you'd need conventions, separate config files, or mountains of if-statements. Attributes let you declare intent right next to the code it describes, and then read that intent at runtime using reflection or at compile time using Roslyn analyzers.
By the end you'll understand exactly what attributes are and how the CLR handles them, how to read them at runtime using reflection, how to write your own custom attributes for real use cases like validation or logging, and the gotchas that trip up even experienced developers. You'll go from copy-pasting [JsonIgnore] to knowing precisely why it does what it does.
What Attributes Actually Are Under the Hood
An attribute in C# is just a class that inherits from System.Attribute. That's it. When the C# compiler sees [Obsolete("Use NewMethod instead")] above your method, it doesn't generate any runtime code at that call site — it embeds metadata into the compiled assembly's IL (Intermediate Language). The attribute instance doesn't even get created until something asks for it via reflection.
This is the key insight most developers miss: attributes are lazy. They cost nothing at runtime unless you read them. The compiled assembly carries the attribute data around like a passport stamp — it's there if anyone checks, but it doesn't slow you down at the border unless the border agent actually looks.
The square-bracket syntax [AttributeName] is just compiler sugar. Writing [Obsolete] is identical to writing [ObsoleteAttribute] — the compiler strips the 'Attribute' suffix automatically when resolving names. And you can pass arguments to the attribute's constructor or set its public properties using named parameters inside the brackets.
Attributes can target specific language elements: classes, methods, properties, fields, parameters, assemblies, and more. You control this with the [AttributeUsage] attribute on your custom attribute class — which is wonderfully meta.
GetCustomAttribute().GetOrAdd to compute once. 1000 requests → 1 reflection call, 999 dictionary lookups.Assembly.GetTypes() once at load time, read attributes, store in Dictionary keyed by Type or MethodInfo.Attribute Targets, AllowMultiple, and Inheritance — The Rules That Bite You
Once you start writing custom attributes, three settings in [AttributeUsage] determine everything: AttributeTargets, AllowMultiple, and Inherited. Getting these wrong is the most common source of confusing bugs.
AttributeTargets is a flags enum, so you can combine targets with the bitwise OR operator: AttributeTargets.Class | AttributeTargets.Method means the attribute is valid on both classes and methods. Use AttributeTargets.All if it genuinely makes sense everywhere, but be conservative — narrow targeting helps other developers avoid misusing your attribute.
AllowMultiple = true means you can stack the same attribute multiple times on one member. This is useful for things like [InlineData(1), InlineData(2)] in xUnit where each instance carries different test data. When AllowMultiple is false (the default) and you accidentally apply the same attribute twice, you get a compile error — which is actually the safe, desirable outcome.
Inherited = true means if ClassA has your attribute and ClassB inherits from ClassA, calling GetCustomAttribute on ClassB will also return the attribute. This is the default and usually what you want. Set it to false when the attribute is specifically about the declaring class, not its descendants — for example, a [DatabaseTable("customers")] attribute mapping a class to a DB table name shouldn't silently inherit to subclasses that might map to different tables.
GetCustomAttribute() results for anything called more than once — store them in a static dictionary at startup, because reflection reads are expensive at scale.Attribute Targets Quick-Reference Table
The AttributeTargets enum defines every code element you can decorate with an attribute. You combine them with bitwise OR (|) in [AttributeUsage]. Below is the complete list of values, each with a description and an example of when you'd use it.
| Value | Applies To | Common Example |
|---|---|---|
Assembly | The entire assembly (file) | [AssemblyTitle("MyApp")] — assembly-level metadata |
Module | A module (DLL/EXE within assembly) | [Module] — rarely used directly |
Class | A class | [Serializable] public class Order { } |
Struct | A struct | [StructLayout(LayoutKind.Sequential)] for interop |
Enum | An enum | [Flags] public enum Permissions { } |
Constructor | A constructor | [Obsolete] public |
Method | A method | [HttpGet] public IActionResult |
Property | A property | [JsonPropertyName("id")] public int Id { get; set; } |
Field | A field | [NonSerialized] private int _cache; |
Event | An event | [field: NonSerialized] public event EventHandler Changed; — field target |
Interface | An interface | [ServiceContract] public interface IMyService { } |
Parameter | A method parameter | [CallerMemberName] string callerName = "" |
Delegate | A delegate type | [UnmanagedFunctionPointer(CallingConvention.Cdecl)] |
ReturnValue | A method return value | [return: MarshalAs(UnmanagedType.Bool)] bool |
GenericParameter | A generic type parameter | Rarely used; [type: ...] syntax |
All | Combination of all targets | Use with caution: [AttributeUsage(AttributeTargets.All)] |
When designing your own attribute, be as specific as possible. For example, if your attribute only makes sense on methods, use AttributeTargets.Method — not All. This gives compile-time validation and makes intent clear.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]. This is valid and common. If you forget to specify any target, the C# compiler defaults to AttributeTargets.All — which is almost always too broad.Visual Reflection Pipeline: How Attributes Are Read and Cached
The pipeline is straightforward but the performance cliff is hidden in the 'No' branch. Each 'No' costs a constructor invocation, property assignment, and heap allocation. At 10,000 RPS, even a 1-microsecond instantiation adds 10ms of CPU time per second — pinning a core at 100%.
The diagram shows the key enabler: a ConcurrentDictionary<MethodInfo, T> sitting between the reflection call and the business logic. The first request suffers the allocation cost; all subsequent requests pay only the dictionary lookup (nanoseconds). For ultra-hot paths, source generators (Roslyn) skip the entire pipeline by generating attribute access code at compile time — the attribute data is baked into static fields, eliminating both reflection and caching.
Attributes and Conditional Compilation — The #if (DEBUG) Pattern
Attributes are metadata attached at compile time, but their presence can be controlled by conditional compilation symbols like DEBUG, RELEASE, or custom symbols. This is useful when you want attribute-driven behaviour only in certain build configurations without modifying the source code.
There are two mechanisms: one is the [Conditional] attribute from System.Diagnostics — it marks a method so that calls to it (including attribute instantiation if the attribute's code uses it) are omitted unless the specified symbol is defined. However, [Conditional] on an attribute class itself only works if the attribute's constructor or property setters are called — but attribute instantiation via reflection always happens at runtime regardless of conditional compilation symbols. So [Conditional] is not the right tool for conditional attribute application.
The correct approach is to use #if SYMBOL / #endif blocks around the attribute application itself. This physically removes the attribute from the source code during compilation when the symbol is not defined. For example, you might want a [LogEveryRequest] attribute that should only exist in debug builds. By wrapping it in #if DEBUG, the attribute is not compiled into the release assembly — reflection will never find it because the metadata token is absent.
This technique is often paired with a using static directive or alias to keep code clean. The key trade-off: conditional compilation removes the attribute entirely for non-debug builds, which means your reflective code must handle the case where the attribute is absent (returning null). This is usually fine — the default behaviour (no attribute) is the release path.
#if DEBUG around attribute applications, the release build has zero reflection cost for that attribute because the metadata doesn't exist. This is the most aggressive optimisation: you don't just cache it, you eliminate it entirely. However, it also means you can't toggle the behaviour at runtime via configuration—it's a compile-time decision. If you need runtime configurability, use a normal attribute combined with a feature flag read at startup.#if SYMBOL to completely remove them from non-debug builds. This gives zero overhead in release but requires the reading code to handle absent attributes gracefully.Caching Implementation — Production-Ready Attribute Cache
The single most impactful optimisation for attribute-heavy code is caching with ConcurrentDictionary. Below is a production-ready implementation that handles thread safety, lazy initialisation, and cleanup for scenarios where types can be unloaded (e.g., dynamic assemblies in plugin systems).
The pattern is straightforward: define a static generic cache class that stores Lazy<TAttribute> values keyed by MethodInfo (or Type, PropertyInfo, etc.). The Lazy<T> ensures that even under concurrent first access, the attribute is instantiated exactly once per key. The dictionary is static and shared across the application domain, so all requests benefit from the same cache.
For high-traffic endpoints (10k+ RPS), this pattern reduces attribute lookup cost from ~1µs to ~50ns — a 20x improvement. That's the difference between 10ms and 0.5ms CPU time per second at 10k RPS.
MethodInfo, Type, PropertyInfo, etc., as cache keys. These are reference types with efficient hash codes and equality checks. Avoid using strings (method names) as keys — they're slower, don't work for overloaded methods, and don't reflect the actual member identity.Attributes in the Real World — ASP.NET Core, JSON, and Data Annotations
You've been using attribute-driven frameworks all along — let's pull back the curtain on three you already know.
In ASP.NET Core, [HttpGet], [HttpPost], and [Route] are custom attributes read by the routing middleware at startup. When the app boots, MVC scans all controller types using reflection, finds methods decorated with HTTP verb attributes, and builds an internal route table. Your method doesn't do anything special — the framework does all the work by reading the metadata.
In System.Text.Json, [JsonPropertyName("order_id")] tells the serializer to map the C# property OrderId to the JSON key order_id. The serializer reads this attribute at serialization time and adjusts its output. [JsonIgnore] tells it to skip the property entirely — useful for passwords or computed values you never want to leak into an API response.
Data Annotations like [Required], [StringLength(100)], and [Range(1, 999)] are read by both ASP.NET Core's model binding (to auto-validate incoming request bodies) and Entity Framework Core (to infer database column constraints). One attribute, two completely separate systems reading it for their own purposes. That's the elegance of metadata: you declare intent once, and any system that cares about it can act on it.
Understanding this pattern means you can build your own mini-frameworks — test runners, CLI argument parsers, config binders — using the same approach the big players use.
The 15ms Attribute Lookup That Took Down Black Friday
System.Reflection.RuntimeMethodInfo.GetCustomAttributes. No database queries were slow; no external API calls timed out. The call stack pointed to a RateLimitAttribute in a custom middleware.GetCustomAttribute allocates a new attribute instance on every call (constructor runs, properties assigned). They also assumed the attribute would be JIT-compiled after first call — but instantiation cost is per call, not per type.[RateLimit(100)] attribute was read inside a middleware for EVERY request. The middleware did: var attr = methodInfo.GetCustomAttribute<RateLimitAttribute>(); No caching. At 10,000 RPS: 10,000 attribute instantiations per second. Each instantiation called the attribute constructor, assigned properties, and allocated a new object on heap (GC pressure). Reflection also performed type hierarchy walking (inheritance chain) and security checks on each call. The team had no caching layer. The CPU spent 35% on reflection alone, bottlenecking the request pipeline. The rest of the API code (database query, JSON serialization) was fine, but the reflection overhead was enough to saturate the CPU.ConcurrentDictionary<MethodInfo, RateLimitAttribute> at startup. Lookup changed from O(reflection + allocation) to O(1) dictionary lookup.
2. Used Lazy<T> to compute attribute once per method: static ConcurrentDictionary<MethodInfo, Lazy<RateLimitAttribute>> _cache.
3. For hot paths, switched to source generators (Roslyn) that generate compile-time attribute code, eliminating reflection entirely.
4. Added a performance test that measures attribute lookup overhead at expected peak RPS.
5. Documented the rule: 'Never call GetCustomAttribute inside a loop. Cache at startup.'- GetCustomAttribute allocates a new attribute instance on EVERY call. It's not cached by the runtime. Cache it yourself.
- Reflection is not 'a bit slow' — it's orders of magnitude slower than direct access. At 10k RPS, microseconds become milliseconds.
- Always profile attribute-heavy code with realistic concurrency. A 1µs lookup becomes 10ms of CPU time at 10k RPS (1µs * 10,000 = 10ms).
- For ultra-hot paths, use source generators (Roslyn) to completely eliminate runtime reflection. The attribute metadata is baked into generated code at compile time.
[AttributeUsage(Inherited = false)][AttributeUsage(AllowMultiple = true)]. Also ensure GetCustomAttributes (plural) is used, not GetCustomAttribute (singular).private static readonly ConcurrentDictionary<MethodInfo, MyAttribute> _cache = new(); then return _cache.GetOrAdd(methodInfo, mi => mi.GetCustomAttribute<MyAttribute>());Key takeaways
GetCustomAttribute(), which is why [JsonIgnore] doesn't slow down your app until the serializer runs.GetCustomAttribute() results for anything called more than onceCommon mistakes to avoid
5 patternsCalling GetCustomAttribute in a tight loop without caching
Forgetting that [AttributeUsage] defaults Inherited to true
Trying to use an attribute's value in an attribute constructor (circular dependency)
typeof(), or enum values in attribute constructors. If you need dynamic data, store a key string in the attribute and look up the real value at runtime from config or a dictionary (after attribute is instantiated).Using GetCustomAttribute instead of GetCustomAttributes when AllowMultiple = true
GetCustomAttributes<T>() (plural), which returns IEnumerable<T>. Iterate over the collection to process all instances. GetCustomAttribute<T>() (singular) returns only the first and makes no guarantee about order.Assuming GetCustomAttribute is thread-safe for caching without synchronisation
_cache.GetOrAdd simultaneously may cause the attribute to be instantiated multiple times (wasting CPU). Worse, if the attribute constructor has side effects or is expensive, you get duplicate work.ConcurrentDictionary<TKey, TValue>.GetOrAdd which is thread-safe. For simple caches, use Lazy<T> to ensure the attribute is instantiated at most once: _cache.GetOrAdd(key, k => new Lazy<Attr>(() => k.GetCustomAttribute<Attr>())).Value.Interview Questions on This Topic
What is the difference between Inherited = true on [AttributeUsage] and passing inherit: true to GetCustomAttributes()? Can you have a situation where one is true and the other is false, and what happens?
Inherited on [AttributeUsage] is a declaration about the attribute's semantics: does the CLR consider this attribute inheritable at all? If Inherited = false, the attribute's metadata is not marked as inheritable at the assembly level. The inherit parameter on GetCustomAttributes is a query instruction: 'should I walk up the inheritance chain to look for attributes?' If Inherited = false on the attribute definition, walking the chain still won't find it because the attribute's metadata flags indicate it's not inheritable. Both must be true for inherited reading to work. Example: [AttributeUsage(Inherited = false)] on the attribute — even with GetCustomAttributes(inherit: true), the attribute won't appear on subclasses. The CLR respects the attribute's metadata flag. The reverse (Inherited = true on attribute, but GetCustomAttributes(inherit: false)) will not walk the chain, so you won't see attributes from base classes. The two flags are independent gates: the attribute definition's Inherited says 'this attribute CAN be inherited'; the query's inherit says 'PLEASE look for inheritance'. Both gates must be open.Frequently Asked Questions
That's C# Advanced. Mark it forged?
7 min read · try the examples if you haven't