BrandGhost
NLog Performance: AsyncWrapper, Buffering, and High-Throughput Logging in .NET

NLog Performance: AsyncWrapper, Buffering, and High-Throughput Logging in .NET

NLog performance becomes critical the moment your application processes more than a few hundred requests per second. A synchronous file write on every _logger.LogInformation(...) call adds latency to every request -- and under load, that latency compounds across your entire request pipeline. NLog ships with two built-in wrappers designed for this: AsyncWrapper for fire-and-forget logging on a background thread, and BufferingWrapper for batching log events before flushing to a target.

This guide covers every aspect of optimizing NLog for high-throughput .NET applications. You'll learn how to configure AsyncWrapper with the right queue size and overflow strategy, when to use BufferingWrapper vs. async, how to benchmark your logging configuration with BenchmarkDotNet, and what configuration choices have the biggest impact on throughput. A production-grade configuration template ties everything together at the end.

This article assumes you already have NLog wired into your ASP.NET Core application and are familiar with targets and configuration basics. If you haven't done that yet, the setup process involves adding the NLog.Web.AspNetCore NuGet package and configuring it in Program.cs -- a process covered in the NLog getting-started material before diving into performance tuning.

Why Synchronous Logging Hurts Performance

By default, NLog writes log events synchronously on the calling thread. This means every _logger.LogWarning(...) call blocks until the write completes -- including disk I/O for file targets and network I/O for database or remote targets.

For a file target, a single synchronous write takes roughly 0.1–1 ms depending on disk speed and OS buffering. At 1,000 requests/second with 5 log events per request, that's 5,000 synchronous writes/second -- each potentially competing for the same file handle. Even with OS-level write caching, contention degrades throughput under load.

The impact is worse for remote targets:

  • Database target: Each log event is an INSERT statement. At 1,000 events/second, that's 1,000 INSERT calls/second -- easily saturating a shared SQL Server instance.
  • Seq/Elasticsearch targets: Each event is an HTTP POST. Latency is 5–50 ms per call, making synchronous writes completely impractical under load.

NLog's solution is target wrappers that decouple the calling thread from the I/O operation.

AsyncWrapper: The Primary Performance Tool

AsyncWrapper wraps any NLog target and moves all I/O to a dedicated background thread. The calling thread enqueues the log event in an in-memory queue and returns immediately -- the background thread drains the queue and performs the actual write.

XML Configuration

The following configuration wraps a File target inside AsyncWrapper. Notice that the File target is the inner target -- AsyncWrapper sits in front of it and manages the background queue. All configuration options on the file target remain fully functional; AsyncWrapper only changes when the write happens, not how.

<targets>
  <target xsi:type="AsyncWrapper"
          name="asyncFile"
          queueLimit="10000"
          overflowAction="Discard"
          batchSize="200"
          timeToSleepBetweenBatches="0">
    <target xsi:type="File"
            name="innerFile"
            fileName="${basedir}/logs/${shortdate}.log"
            layout="${longdate}|${level:uppercase=true}|${logger:shortName=true}|${message}${exception:format=tostring}"
            keepFileOpen="true"
            concurrentWrites="false" />  <!-- concurrentWrites removed in NLog 6; omit on NLog 6+ -->
  </target>
</targets>

JSON Configuration (appsettings.json)

The same AsyncWrapper setup expressed in appsettings.json works identically at runtime. The JSON format is preferred when your DevOps pipeline injects connection strings or log levels via environment variables -- ASP.NET Core's configuration system merges appsettings.Production.json and environment variables automatically without any code changes.

{
  "NLog": {
    "targets": {
      "asyncFile": {
        "type": "AsyncWrapper",
        "queueLimit": 10000,
        "overflowAction": "Discard",
        "batchSize": 200,
        "timeToSleepBetweenBatches": 0,
        "target": {
          "type": "File",
          "name": "innerFile",
          "fileName": "${basedir}/logs/${shortdate}.log",
          "keepFileOpen": true
          // "concurrentWrites": false  -- removed in NLog 6; omit on NLog 6+
        }
      }
    }
  }
}

Key AsyncWrapper Parameters

The default values for AsyncWrapper are conservative, chosen to be safe for single-server apps rather than optimal for high-throughput services. For production applications under serious load, each of these parameters should be reviewed and tuned against your specific traffic profile and memory constraints.

Parameter Default Recommended (high load) Notes
queueLimit 10000 50000–100000 Max events in memory before overflow action fires
overflowAction Discard Discard or Block Discard drops events when queue is full; Block applies back-pressure
batchSize 200 500–1000 Events written per background thread iteration
timeToSleepBetweenBatches 50 (ms) 0 Set to 0 for maximum throughput; non-zero reduces CPU

overflowAction trade-offs:

  • Discard -- drops log events when the queue is full. Caller never blocks. Log events are silently lost during extreme spikes. Use for high-priority services where availability matters more than log completeness.
  • Block -- caller blocks when the queue is full until space is available. No events are lost, but your request handler blocks. Useful when log completeness is a compliance requirement.
  • Grow -- expands the queue without bound. Avoid in production -- unbounded memory growth during a traffic spike will cause out-of-memory errors.

async="true" Shorthand in JSON Config

In appsettings.json, adding async: true at the top of the targets section automatically wraps all targets in AsyncWrapper with default parameters. This is the zero-friction option for applications that don't need fine-grained control:

{
  "NLog": {
    "targets": {
      "async": true,
      "file": {
        "type": "File",
        "fileName": "${basedir}/logs/${shortdate}.log"
      }
    }
  }
}

This is equivalent to wrapping each target in AsyncWrapper with queueLimit=10000 and overflowAction=Discard. For tuned configurations, use explicit AsyncWrapper setup instead.

BufferingWrapper: Batch Writes for Database and Remote Targets

BufferingWrapper accumulates log events in memory and flushes them in batches when either the buffer is full or a flush interval expires. Unlike AsyncWrapper, it is synchronous by default -- the background thread sends a batch, then waits for the flush to complete before draining more events.

Use BufferingWrapper when your target benefits from batched I/O -- database targets (one INSERT per batch vs. one per event), and HTTP-based targets (one HTTP call per batch vs. per event).

<targets>
  <target xsi:type="BufferingWrapper"
          name="bufferedDb"
          bufferSize="100"
          flushTimeout="5000"
          slidingTimeout="false">
    <target xsi:type="Database"
            name="innerDb"
            dbProvider="MySql.Data.MySqlClient"
            connectionString="${environment:DB_CONNECTION_STRING}"
            commandText="INSERT INTO Logs (Timestamp, Level, Logger, Message, Exception)
                         VALUES (@time, @level, @logger, @msg, @exc)">
      <parameter name="@time" layout="${date:universalTime=true:format=o}" />
      <parameter name="@level" layout="${level:uppercase=true}" />
      <parameter name="@logger" layout="${logger:shortName=true}" />
      <parameter name="@msg" layout="${message}" />
      <parameter name="@exc" layout="${exception:format=tostring}" />
    </target>
  </target>
</targets>

The bufferSize="100" flushes when 100 events accumulate. The flushTimeout="5000" flushes any remaining events after 5 seconds even if the buffer is not full. This ensures low-traffic periods still persist log events in a timely manner.

Combining AsyncWrapper and BufferingWrapper

For maximum throughput on a database target, wrap BufferingWrapper inside AsyncWrapper:

<targets>
  <target xsi:type="AsyncWrapper" name="asyncBufferedDb" queueLimit="50000" overflowAction="Discard">
    <target xsi:type="BufferingWrapper" bufferSize="200" flushTimeout="10000">
      <target xsi:type="Database" name="db" ... />
    </target>
  </target>
</targets>

This configuration:

  1. AsyncWrapper returns immediately to the caller -- no blocking
  2. BufferingWrapper accumulates events in the background thread before flushing
  3. Database target receives batches of 200 events per INSERT cycle

The result is dramatically fewer database round-trips compared to a synchronous per-event setup. NLog's Database target supports batching natively through this wrapper combination, making it practical for production write-heavy services.

File Target Performance Tuning

Even with AsyncWrapper moving writes off the calling thread, the file target's own configuration shapes how fast the background thread can drain the queue. Two settings in particular have a measurable impact on throughput and are frequently left at their defaults, which are not optimal for high-volume applications.

keepFileOpen

<!-- NLog 5.x: keepFileOpen + concurrentWrites="false" for single-process writers -->
<!-- NLog 6:   keepFileOpen only -- ConcurrentWrites was removed -->
<target xsi:type="File"
        fileName="${basedir}/logs/app.log"
        keepFileOpen="true"
        concurrentWrites="false" />  <!-- NLog 5.x only; omit on NLog 6+ -->

In NLog 5.x, keepFileOpen defaulted to false -- NLog opened and closed the file handle on every write. NLog 6 changed this default to true. Regardless of which version you're on, explicitly setting keepFileOpen="true" eliminates ambiguity and the repeated open/close system call overhead. This alone can double throughput on file targets.

When keepFileOpen="true" on NLog 5.x, also set concurrentWrites="false" unless multiple processes write to the same file -- the concurrent write support adds locking overhead that's unnecessary for single-process writers. Note: NLog 6 removed ConcurrentWrites from FileTarget entirely. If you're on NLog 6, omit concurrentWrites from your configuration.

Archive and Rolling Patterns

Rolling log files by date or size avoids unbounded file growth. However, the rolling check has a cost -- NLog checks the rolling condition on every write. For high-throughput applications, use archiveAboveSize with a large value (e.g., 104857600 for 100 MB) rather than hourly rolling, which minimizes the frequency of roll-over events:

<target xsi:type="File"
        fileName="${basedir}/logs/app.log"
        archiveFileName="${basedir}/logs/archive/app.{#}.log"
        archiveAboveSize="104857600"
        archiveNumbering="Rolling"
        maxArchiveFiles="10"
        keepFileOpen="true"
        concurrentWrites="false" />  <!-- NLog 5.x only; omit on NLog 6+ -->

Benchmarking NLog Configuration with BenchmarkDotNet

Before optimizing, measure. Intuitions about which configuration is fastest are often wrong -- AsyncWrapper with a full queue and Block overflow is slower than synchronous logging for small bursts.

BenchmarkDotNet is the standard tool for .NET microbenchmarks. Here's how to benchmark NLog configurations:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.Logging;
using NLog;
using NLog.Extensions.Logging;

public class NLogBenchmark
{
    private Microsoft.Extensions.Logging.ILogger _syncLogger = null!;
    private Microsoft.Extensions.Logging.ILogger _asyncLogger = null!;

    [GlobalSetup]
    public void Setup()
    {
        // Synchronous file target
        var syncConfig = new NLog.Config.LoggingConfiguration();
        var syncTarget = new NLog.Targets.FileTarget("syncFile")
        {
            FileName = "${basedir}/logs/sync-bench.log",
            KeepFileOpen = true,
            ConcurrentWrites = false
        };
        syncConfig.AddRuleForAllLevels(syncTarget);
        var syncFactory = LoggerFactory.Create(b => b.AddNLog(syncConfig));
        _syncLogger = syncFactory.CreateLogger<NLogBenchmark>();

        // Async file target
        var asyncConfig = new NLog.Config.LoggingConfiguration();
        var asyncTarget = new NLog.Targets.Wrappers.AsyncTargetWrapper(
            new NLog.Targets.FileTarget("asyncFile")
            {
                FileName = "${basedir}/logs/async-bench.log",
                KeepFileOpen = true,
                ConcurrentWrites = false
            })
        {
            QueueLimit = 50000,
            OverflowAction = NLog.Targets.Wrappers.AsyncTargetWrapperOverflowAction.Discard
        };
        asyncConfig.AddRuleForAllLevels(asyncTarget);
        var asyncFactory = LoggerFactory.Create(b => b.AddNLog(asyncConfig));
        _asyncLogger = asyncFactory.CreateLogger<NLogBenchmark>();
    }

    [Benchmark(Baseline = true)]
    public void SynchronousFileLogging()
    {
        _syncLogger.LogInformation("Order {OrderId} processed in {ElapsedMs}ms", 12345, 42);
    }

    [Benchmark]
    public void AsyncWrapperLogging()
    {
        _asyncLogger.LogInformation("Order {OrderId} processed in {ElapsedMs}ms", 12345, 42);
    }
}

class Program
{
    static void Main() => BenchmarkRunner.Run<NLogBenchmark>();
}

When running these benchmarks, AsyncWrapper often measures faster per call in microbenchmarks for file targets because the caller only enqueues the event -- the actual I/O happens on the background thread and is not measured in the per-call number. End-to-end throughput depends on I/O speed, queue depth, and background thread scheduling, so always validate under realistic concurrent load. For comprehensive BenchmarkDotNet usage, the guide at How to Use BenchmarkDotNet covers the key options.

Avoiding async void Anti-Patterns in Custom Targets

If you write a custom NLog target that does async I/O (e.g., posting to a webhook), it's tempting to make Write async. Avoid this:

// ❌ Dangerous -- async void, exceptions are unobserved
protected override async void Write(LogEventInfo logEvent)
{
    await _httpClient.PostAsync(_webhookUrl, BuildContent(logEvent));
}

NLog's Write method is synchronous by design. async void methods fire and forget with no way to track completion or catch exceptions -- a failing HTTP call silently kills the method. The async void pattern is discussed in detail at Async Void Methods in C#: The Dangers.

Instead, use Task.Run to fire the work onto a thread pool thread, or override WriteAsyncTask which is the correct async extension point in NLog:

// ✅ Correct async target extension point
protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken cancellationToken)
{
    await _httpClient.PostAsync(_webhookUrl, BuildContent(logEvent), cancellationToken);
}

When overriding WriteAsyncTask, NLog manages the async scheduling. Wrap the call in AsyncWrapper and NLog coordinates flushing correctly on application shutdown.

AOT and Source Generation Considerations

NLog 6 supports Native AOT with some limitations. AsyncWrapper and BufferingWrapper are both AOT-compatible in NLog 6. However, your app still needs to avoid reflection-heavy config and extension loading without explicit trimming hints -- AOT compatibility covers the wrappers themselves, not arbitrary reflection-based target scanning. If you register NLog targets or extensions viareflection scanning (e.g., using Scrutor or a similar library), prefer explicit registration or a source-generator approach when publishing with trimming enabled. The Automatic Dependency Injection in C# with Needlr guide covers source-generation-based registration that is AOT-safe -- the same principles apply when you need to register custom NLog target types at startup without reflection.

Production Performance Template

Here is a complete, ready-to-use high-throughput NLog configuration for an ASP.NET Core application. It combines all the techniques covered above -- async wrappers per target, buffering on the structured JSON target, file tuning, and production-grade rules that silence framework noise while preserving full application output.

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true">

  <targets>
    <!-- Async console target (dev-friendly, low overhead) -->
    <target xsi:type="AsyncWrapper"
            name="asyncConsole"
            queueLimit="10000"
            overflowAction="Discard">
      <target xsi:type="ColoredConsole"
              name="console"
              layout="${time}|${level:uppercase=true:padding=-5}|${logger:shortName=true}|${message}${exception:format=message}" />
    </target>

    <!-- Async rolling file target (high throughput) -->
    <target xsi:type="AsyncWrapper"
            name="asyncFile"
            queueLimit="50000"
            overflowAction="Discard"
            batchSize="500"
            timeToSleepBetweenBatches="0">
      <target xsi:type="File"
              name="file"
              fileName="${basedir}/logs/${shortdate}.log"
              archiveFileName="${basedir}/logs/archive/app.{#}.log"
              archiveAboveSize="104857600"
              archiveNumbering="Rolling"
              maxArchiveFiles="10"
              keepFileOpen="true"
              concurrentWrites="false"  <!-- NLog 5.x only; omit on NLog 6+ -->
              layout="${longdate}|${level:uppercase=true}|${mdlc:CorrelationId}|${logger:shortName=true}|${message}${exception:format=tostring}" />
    </target>

    <!-- Async buffered structured JSON target (for log aggregation) -->
    <target xsi:type="AsyncWrapper"
            name="asyncJson"
            queueLimit="100000"
            overflowAction="Discard">
      <target xsi:type="BufferingWrapper"
              name="bufferedJson"
              bufferSize="500"
              flushTimeout="5000">
        <target xsi:type="File"
                name="jsonFile"
                fileName="${basedir}/logs/structured-${shortdate}.json"
                keepFileOpen="true"
                concurrentWrites="false">  <!-- NLog 5.x only; omit on NLog 6+ -->
          <layout xsi:type="JsonLayout" includeAllProperties="true" excludeEmptyProperties="true">
            <attribute name="timestamp" layout="${date:universalTime=true:format=o}" />
            <attribute name="level" layout="${level:uppercase=true}" />
            <attribute name="logger" layout="${logger:shortName=true}" />
            <attribute name="correlationId" layout="${mdlc:CorrelationId}" />
            <attribute name="message" layout="${message}" />
            <attribute name="exception" layout="${exception:format=tostring}" />
          </layout>
        </target>
      </target>
    </target>
  </targets>

  <rules>
    <!-- Suppress EF Core query logging -->
    <logger name="Microsoft.EntityFrameworkCore.Database.Command" maxlevel="Info" final="true" />
    <!-- Suppress all Microsoft.*/System.* Debug/Info -->
    <logger name="Microsoft.*" maxlevel="Info" final="true" />
    <logger name="System.*" maxlevel="Info" final="true" />
    <!-- Framework warnings → file only -->
    <logger name="Microsoft.*" minlevel="Warn" writeTo="asyncFile" final="true" />
    <!-- Application → all targets -->
    <logger name="*" minlevel="Debug" writeTo="asyncConsole,asyncFile,asyncJson" />
  </rules>
</nlog>

This configuration provides:

  • Non-blocking async for all three targets
  • 500-event batching on the JSON target to reduce flush frequency
  • Independent queue limits per target (console queue is smaller since console is fast)
  • Production rules that silence framework noise while preserving all application output

Frequently Asked Questions

What is the difference between AsyncWrapper and async="true" in NLog?

async="true" in appsettings.json automatically wraps every target in AsyncWrapper with default parameters (queueLimit=10000, overflowAction=Discard). An explicit AsyncWrapper lets you tune queueLimit, overflowAction, batchSize, and timeToSleepBetweenBatches per target. Use async="true" for quick setups and explicit AsyncWrapper when you need fine-grained throughput control for specific targets.

What should I set queueLimit to in NLog AsyncWrapper?

A reasonable starting point is 10,000–50,000 events. The right value depends on your burst traffic profile and available memory. At 200 bytes per event (approximate average), a 50,000-event queue uses about 10 MB. Monitor your queue utilization under load using NLog's internal logging (internalLogLevel="Warn") to see if events are being discarded. If they are, increase queueLimit or reduce event volume at the source.

Does AsyncWrapper prevent log events from being lost on application shutdown?

AsyncWrapper flushes its queue on shutdown via NLog.LogManager.Shutdown(). If you use NLog.Web.AspNetCore, the flush is called automatically when the host shuts down. If you wire NLog manually, call LogManager.Shutdown() in IHostApplicationLifetime.ApplicationStopped to ensure queued events are written before the process exits.

When should I use BufferingWrapper instead of AsyncWrapper?

Use BufferingWrapper when your target benefits from batch I/O -- most commonly database targets and HTTP-based targets. A single INSERT with 100 rows is much faster than 100 separate INSERTs. AsyncWrapper makes the caller non-blocking; BufferingWrapper reduces I/O round-trips. Use both together for the best of both: non-blocking caller plus batched writes.

Does keepFileOpen="true" break log rotation on Linux?

On Linux, a file with an open handle can still be deleted (the inode remains until all handles are released). Log rotation tools like logrotate use the copytruncate option to work with applications that keep files open. NLog also supports autoFlush="true" and the ArchiveOldFileOnStartup option. For containerized deployments, write to stdout and let the container runtime handle log collection -- which sidesteps file rotation entirely.

How do I measure the actual throughput of my NLog configuration?

Use BenchmarkDotNet with realistic message payloads and real targets (not null targets), and measure under concurrent load that matches your production request pattern. Single-threaded benchmarks underestimate contention on the file handle or queue. Test with multiple concurrent Task instances calling the logger to simulate real-world producer/consumer load on the AsyncWrapper queue.

Wrapping Up

NLog performance optimization comes down to three principles: move I/O off the calling thread with AsyncWrapper, reduce I/O round-trips with BufferingWrapper, and configure file targets to keep files open. Most applications can achieve sub-microsecond per-call logging latency with these tools properly configured.

The key takeaways:

  • AsyncWrapper is the first and most important optimization -- it turns every logging call from a blocking I/O operation into an in-memory queue enqueue
  • Tune queueLimit and overflowAction per target based on the target's write speed and your tolerance for dropped events
  • Combine AsyncWrapper + BufferingWrapper for database and HTTP targets to batch I/O and reduce network round-trips
  • keepFileOpen="true" is free throughput for single-process file targets; on NLog 5.x, pair with concurrentWrites="false" (NLog 6 removed ConcurrentWrites from FileTarget)
  • Benchmark with BenchmarkDotNet under realistic concurrent load before and after changes

Once you've optimized NLog for throughput, the next architectural question is often whether NLog is the right choice for your project. NLog and Serilog have different strengths -- NLog's rules-based routing is more powerful for complex multi-target scenarios, while Serilog's sink ecosystem is broader and its C# fluent API is more natural for teams that prefer code-first configuration.

NLog in .NET: Complete Guide to Flexible Logging

Master NLog in .NET with this complete guide. Learn targets, rules, layout renderers, structured logging, and performance optimization for C# applications.

Weekly Recap: NLog in .NET, OpenTelemetry, and Engineering Careers [Aug 2026]

This week covers NLog setup, targets, layout renderers, and structured logging in .NET. Plus, explore OpenTelemetry distributed tracing and practical career questions about difficult peers and what comes after senior engineer.

Getting Started with NLog in ASP.NET Core

Learn how to set up NLog in ASP.NET Core step by step. Covers XML nlog.config and appsettings.json configuration, Program.cs setup, Worker Services, and ILogger usage.

An error has occurred. This application may no longer respond until reloaded. Reload