👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
High Performance Logging in .NET

High Performance Logging in .NET

Author - Abdul Rahman (Bhai)

Logging

5 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Logging is a cross-cutting concern. It can appear in every layer of your application. And if you do it wrong, you will do it consistently wrong — everywhere, all at once. That is a recipe for both bad logs and bad performance.

In this article, you will learn two powerful .NET techniques designed for high-performance logging in hot paths: the LoggerMessage.Define method and the logging source generator. Both are built into Microsoft.Extensions.Logging and produce the fastest, most memory-efficient logs possible in .NET.

By the end of this article you will understand:

  • Why standard logger.LogInformation calls can be costly in hot paths
  • How LoggerMessage.Define eliminates those costs
  • How the [LoggerMessage] source generator gives you the same performance with a clean, maintainable API

Why we gonna do?

Why think about performance in logging at all? Logs are just side effects, right? Not quite. Logging is a cross-cutting concern — and in high-throughput services, each log call on a hot path adds up fast.

The hidden cost of standard logging

Consider the most natural way to log in .NET:


// Standard approach
_logger.LogInformation(
    "Customer {Email} created a payment of {Amount} for product {ProductId}",
    email, amount, productId);
            

This looks fine. But under the hood, every call to this method:

  • The params object[] overload — the one used by every plain LogInformation(template, args...) call — boxes value types like decimal and int into object on every invocation
  • Allocates a new array on the heap to hold those boxed arguments
  • Performs these allocations even when the log level is disabled

On a cold path this is inconsequential. On a hot path — a loop, an event handler, a high-frequency request pipeline — it becomes a continuous source of allocations and GC pressure.

Recognising a hot path

A hot path is any code that executes very frequently: tight loops, message processing loops, middleware that fires on every request, or event-driven handlers that fire thousands of times per second. In these contexts, even micro-allocations compound rapidly into measurable latency and throughput degradation.

These two techniques solve the problem at its root by moving the expensive work to compile time, and by short-circuiting the allocation entirely when the target log level is not enabled.

How we gonna do?

Step 1 — LoggerMessage.Define

LoggerMessage.Define is a factory method that creates a strongly typed, cached Action delegate for a specific log message at application startup. The formatter is compiled once and reused for every subsequent call. There is no boxing, no array allocation.

Here is how to define and use it in a service class:


using Microsoft.Extensions.Logging;

public class PaymentService
{
    // Define the log action once — compiled at startup, reused forever
    private static readonly Action<ILogger, string, decimal, int, Exception?>
        _logPaymentCreated =
            LoggerMessage.Define<string, decimal, int>(
                LogLevel.Information,
                new EventId(1, "PaymentCreated"),
                "Customer {Email} created a payment of {Amount} for product {ProductId}");

    private readonly ILogger<PaymentService> _logger;

    public PaymentService(ILogger<PaymentService> logger)
    {
        _logger = logger;
    }

    public void CreatePayment(string email, decimal amount, int productId)
    {
        // Perform payment logic here...

        // Invoke the pre-compiled delegate — zero boxing, zero array allocation
        _logPaymentCreated(_logger, email, amount, productId, null);
    }
}
            

The generic type parameters on Define<string, decimal, int> match the three message template placeholders. The last argument in the generated Action is always an Exception? — pass null when there is no exception to attach.

When you do have an exception — for example, inside a catch block — pass it as the final argument instead of null. It will be attached to the structured log entry and surfaced automatically by any log store:


try
{
    // payment logic...
}
catch (Exception ex)
{
    // Attach the exception to the structured log entry
    _logPaymentCreated(_logger, email, amount, productId, ex);
}
            

This works and is maximally fast. But the developer experience has some rough edges: you must manage a private static field, and you must pass the injected _logger as a parameter into the delegate even though it was injected. It is awkward, and nullable exceptions add noise. There is a better way.

Step 2 — The [LoggerMessage] source generator

The [LoggerMessage] source generator, introduced in .NET 6, gives you the exact same performance as LoggerMessage.Define but with a clean, idiomatic API. You declare your intent; the compiler generates the optimised code.

Make the class partial and annotate methods with [LoggerMessage]:


using Microsoft.Extensions.Logging;

public partial class PaymentService(ILogger<PaymentService> logger)
{
    private readonly ILogger<PaymentService> _logger = logger;

    [LoggerMessage(
        EventId = 1,
        Level = LogLevel.Information,
        Message = "Customer {Email} created a payment of {Amount} for product {ProductId}")]
    partial void LogPaymentCreation(
        string email,
        decimal amount,
        int productId);

    public void CreatePayment(string email, decimal amount, int productId)
    {
        // Perform payment logic here...

        // Clean, readable, and maximally efficient
        LogPaymentCreation(email, amount, productId);
    }
}
            

Two things are required: the class must be partial, and the method must be partial as well. The partial keyword is how you tell the compiler that another file will contribute part of the definition — in this case, the source generator writes the method body into a separate generated file at compile time, and you never write the implementation yourself.

The static modifier is only needed when you want to define log methods as ILogger extension methods in a dedicated helper class. When the logger is already injected into the class, instance partial methods are cleaner and avoid passing this ILogger explicitly.

The generated code automatically checks whether the target log level is enabled before doing anything else. If Information is not enabled for this logger, the method returns immediately — no boxing, no allocation, no string formatting. Zero cost.

Comparison at a glance


// ❌ Standard logging — boxing + array allocation on every call
_logger.LogInformation(
    "Customer {Email} created a payment of {Amount} for product {ProductId}",
    email, amount, productId);

// ✅ LoggerMessage.Define — no allocation, but verbose to maintain
_logPaymentCreated(_logger, email, amount, productId, null);

// ✅ [LoggerMessage] source generator — no allocation, clean API
LogPaymentCreation(email, amount, productId);
            

| Approach                       | Level disabled?  | Heap allocation? |
|--------------------------------|------------------|------------------|
| LogInformation (standard)      | Still executes   | Yes — always     |
| LoggerMessage.Define           | Returns early    | No               |
| [LoggerMessage] source gen     | Returns early    | No               |
            

When to use these techniques

These techniques are not for everywhere — they require more setup than a plain LogInformation call. Apply them in:

  • Tight loops that fire thousands of times per second
  • Middleware that executes on every request
  • Message consumers in event-driven systems
  • Any code path identified as hot by a profiler

If you can afford the slightly higher maintenance burden, the [LoggerMessage] source generator is the recommended approach for all logging — not just hot paths. It is the highest-performance logging option available in .NET today, and it is not significantly harder to write once you are familiar with the pattern.

Summary

In this article, we explored two .NET techniques that take logging performance to its absolute maximum:

  • LoggerMessage.Define creates a pre-compiled, cached delegate at startup. It eliminates boxing and heap allocation on every log call, making it ideal for hot paths.
  • The [LoggerMessage] source generator delivers the same zero-allocation performance with a clean, maintainable extension-method API. The compiler generates the implementation — you only declare the intent.
  • Both techniques check whether the target log level is enabled before doing any work, ensuring zero-cost logging when the level is disabled.
  • For general use, prefer the [LoggerMessage] source generator. It is the best balance of performance, readability, and maintainability available in .NET today.
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Logging
  • Logging
  • High Performance Logging
  • LoggerMessage
  • Source Generator
  • LoggerMessage.Define
  • Hot Path
  • Boxing
  • ILogger
  • .NET