Performance Trade-offs of spdlog's Async Logging Mode: Architecture, Queues, and Tuning

spdlog's asynchronous mode eliminates producer blocking by routing log records through a lock-free queue serviced by a dedicated thread pool, trading microsecond-scale latency for massive throughput gains while requiring careful tuning of queue depth and overflow policies.

Asynchronous logging in spdlog decouples message generation from disk I/O by moving the heavy lifting to a background thread pool. Understanding the performance trade-offs of spdlog's async logging mode is essential when building high-throughput applications that cannot tolerate stalls caused by slow sinks. This analysis examines the underlying architecture—from the global queue to backend processing—based on the implementation in the gabime/spdlog repository.

Architectural Overview

The async implementation centers on three main components: a global thread pool that manages a fixed-size queue, the async_logger class that forwards messages, and backend functions that execute the actual sink operations.

Global Thread Pool and Queue

When you request an async logger, the factory invokes async_factory_impl::create, which initializes a global thread pool on first use. According to [async.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async.h#L27-L48), this pool maintains a lock-free queue with a default size of 8192 slots and spawns worker threads that continuously pop messages and dispatch them to the registered sinks.

async_logger Forwarding

The async_logger class, declared in [async_logger.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async_logger.h#L21-L27), stores a std::weak_ptr to the thread pool and an overflow policy. When you invoke a log function, sink_it_ calls post_log, which enqueues the message rather than calling the sink directly. This non-blocking enqueue is what keeps the producer thread running at full speed.

Backend Processing

Once a message reaches the front of the queue, a worker thread invokes backend_sink_it_ and backend_flush_, defined in [async_logger-inl.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async_logger-inl.h#L60-L78). These functions perform the actual formatting and I/O operations, meaning the heavy work happens off the critical path of your application code.

Performance Trade-offs

Enabling async mode changes the performance characteristics of your logging pipeline in several specific ways.

Throughput vs. Latency

Throughput increases dramatically because the producer thread performs only a lock-free enqueue (an atomic operation) instead of potentially blocking on disk I/O. However, latency increases: the message must wait in the queue until a worker thread processes it and completes the write. For typical configurations, this adds a few microseconds to single-digit milliseconds, depending on queue depth and I/O speed.

Queue Size and Memory Pressure

The q_size parameter (default 8192) determines how many log records can buffer during bursts. A larger queue smooths out spikes and prevents stalling, but each slot consumes memory for the message string and metadata. If your application generates sustained bursts larger than the queue capacity, you must either accept higher memory usage or risk triggering the overflow policy.

Overflow Policy Behavior

When the queue fills, spdlog applies one of three overflow policies, as enumerated in [async_logger.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async_logger.h#L21-L27):

  • block – The producer thread waits until a slot frees. This guarantees zero message loss but can pause your application indefinitely if the consumer is stalled.
  • overrun_oldest – The oldest message is discarded to make room for the new one. This maintains steady throughput for telemetry scenarios where recent data matters more than historical continuity.
  • discard_new – The incoming message is dropped. This prevents producer stalls but loses the most recent log record during high-pressure situations.

Thread Pool Sizing

The thread pool size determines how many sinks can process messages concurrently. For a single file sink, one thread is sufficient because the I/O is sequential. However, if you log to multiple files or network destinations, increasing the worker count (via init_thread_pool) can parallelize output, though each additional thread adds context-switch overhead.

Synchronization Costs

Unlike synchronous loggers, which lock a mutex for every message if the sink is shared, the async mode uses a lock-free queue based on atomics. Under heavy contention (millions of messages per second), this typically outperforms mutex-based locking, but it introduces cache coherency traffic between producer and consumer cores.

Destruction Guarantees

When an async_logger is destroyed, the destructor ensures that all queued messages are processed before resource cleanup occurs, as noted in the thread pool implementation. This prevents log loss during shutdown but can delay application exit if the queue is deep and the sink is slow.

Configuration and Tuning

To maximize performance while avoiding pitfalls, tune these parameters based on your workload:

  • Queue size – Start with the default 8192; increase to 32k or 64k if you observe stalls under burst loads.
  • Overflow policy – Use block for debugging and audit trails where data integrity is paramount; switch to overrun_oldest for high-frequency metrics where timeliness outweighs completeness.
  • Thread count – Match the number of worker threads to the number of independent blocking sinks; for single-file logging, one thread is optimal.
  • Memory alignment – Ensure queued messages do not capture large heap buffers to minimize queue memory footprint.

Practical Code Examples

// -------------------------------------------------
// 1️⃣ Create a blocking async logger (default)
//    Uses the global thread pool with block policy
// -------------------------------------------------
auto async_file = spdlog::async_factory::create<spdlog::sinks::basic_file_sink_mt>(
    "async_file", "logs/app.log");

// -------------------------------------------------
// 2️⃣ Non-blocking async logger that drops oldest messages
// -------------------------------------------------
auto async_nb = spdlog::async_factory_nonblock::create<spdlog::sinks::basic_file_sink_mt>(
    "async_nb", "logs/app_nb.log");

// -------------------------------------------------
// 3️⃣ Custom thread pool: 32k queue, 2 worker threads
// -------------------------------------------------
spdlog::init_thread_pool(32'000, 2);
auto custom = spdlog::create_async<spdlog::sinks::stdout_color_sink_mt>(
    "custom_async");

// -------------------------------------------------
// 4️⃣ Logging returns immediately; flush is also queued
// -------------------------------------------------
custom->info("High-frequency log message");
custom->flush();  // Request queued; see async_logger-inl.h#L49-L55

Implementation details: The async_factory_nonblock template selects the overrun_oldest policy, while async_factory defaults to block. The init_thread_pool call replaces the default pool for all subsequent async loggers.

Key Source Files

File Purpose Direct Link
include/spdlog/async.h Global thread-pool creation, factory templates, default queue size constants View source
include/spdlog/async_logger.h async_logger class declaration and overflow policy enum View source
include/spdlog/async_logger-inl.h Inline implementations of post_log, backend_sink_it_, and backend_flush_ View source
src/async.cpp Concrete thread-pool and queue mechanics (backing implementation) View source
bench/async_bench.cpp Benchmarks comparing synchronous vs. asynchronous throughput View source

Summary

  • Async mode decouples logging from I/O using a lock-free queue and background thread pool, maximizing throughput at the cost of slightly increased latency.
  • Overflow policies define the behavior under saturation: block preserves data but stalls producers, while overrun_oldest and discard_new prioritize availability over completeness.
  • Queue sizing balances burst tolerance against memory consumption; the default 8192 slots suits most workloads.
  • Thread pool sizing should match the degree of parallel I/O required; a single worker thread is sufficient for sequential file sinks.
  • Destruction semantics ensure all queued messages are flushed before logger destruction, preventing data loss during shutdown.

Frequently Asked Questions

When should I choose async logging over sync logging in spdlog?

Use async mode when your application generates high volumes of logs (hundreds of thousands per second) or when the logging thread cannot tolerate pauses for disk or network I/O, such as in real-time audio processing or game loops. Stick to sync mode for low-volume debugging sessions where immediate visibility and minimal latency are paramount.

What happens if the async queue fills up?

The behavior depends on the configured overflow policy. With the default block policy, the producer thread pauses until space becomes available. If you configure overrun_oldest, the logger discards the oldest message to accept the new one; with discard_new, the incoming message is dropped immediately.

How many threads should I allocate to the spdlog thread pool?

Allocate one thread per independent blocking sink. For example, if you write to a single local file, one thread is optimal because file I/O is sequential. If you simultaneously log to a file, a database, and a TCP socket, consider two to three threads to parallelize those distinct I/O channels without excessive context switching.

Does spdlog guarantee message ordering in async mode?

Yes, within a single logger instance, messages maintain FIFO ordering because the lock-free queue is strictly sequential. However, if you use multiple async loggers with different sinks, the interleaving of their output depends on OS scheduling and is not globally ordered across logger instances.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →