Synchronous vs Asynchronous Logger Performance in spdlog: A Complete Guide

Synchronous loggers execute log calls in the caller's thread with full I/O blocking, while asynchronous loggers offload formatting and sink writes to a background thread pool via a lock-free queue, dramatically reducing caller latency at the cost of memory and queue management overhead.

The spdlog library provides two distinct logger implementations that serve different performance requirements. Understanding their architectural differences helps developers choose the right approach for high-throughput applications, latency-sensitive systems, or resource-constrained environments.

Execution Model Differences

Synchronous Logger: Direct Path

The spdlog::logger class, defined in [include/spdlog/logger.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/logger.h), processes every log call directly in the calling thread:

// From logger.h lines 8-13 - simplified call chain
if (should_log(lvl)) {
    log_msg msg{...};
    sink_it_(msg);  // Direct dispatch to each sink
}

Three sequential steps occur on every log call:

  1. Level check — should_log(lvl) validates the message passes the configured threshold
  2. Message construction — A log_msg object is populated with timestamp, level, and formatted text
  3. Sink iteration — The logger calls each sink's log() method, executing formatters and I/O operations synchronously

The calling thread blocks until all sinks complete. For file or network sinks, this means full I/O latency exposure to application code.

Asynchronous Logger: Queued Offload

The spdlog::async_logger in [include/spdlog/async_logger.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async_logger.h) overrides sink_it_() to enqueue messages instead of processing them:

// From async_logger.h lines 10-14 - conceptual override
void sink_it_(const details::log_msg &msg) override {
    // Push copy to thread pool queue
    thread_pool_->post_log(shared_from_this(), msg, overflow_policy_);
}

The caller thread performs minimal work:

  • Copies the log_msg into the lock-free MPMC queue
  • Returns immediately (non-blocking unless queue is full)

A dedicated background thread (or thread pool) continuously dequeues messages via process_next_msg_() in [include/spdlog/details/thread_pool.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/details/thread_pool.h) and executes the actual sink writes.

Performance Characteristics Comparison

Metric Synchronous Logger Asynchronous Logger
Caller latency Full formatting + I/O time Queue insertion time only (~100-500ns typical)
Peak throughput Limited by slowest sink Decoupled from sink speed via buffering
Memory footprint Transient log_msg per call Bounded queue + pending messages
CPU usage Caller-bound, predictable Background thread overhead
Burst handling Head-of-line blocking Queue absorbs bursts (with policy tradeoffs)

The queue insertion cost for async loggers typically measures in hundreds of nanoseconds on modern hardware, versus microseconds to milliseconds for disk or network I/O in synchronous mode.

Overflow Policies and Latency Guarantees

The async_overflow_policy enum in [include/spdlog/async_logger.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async_logger.h#L21-L27) determines behavior when the bounded queue fills:

  • block — Producer thread waits until space available; guarantees message delivery at latency cost
  • overrun_oldest — Discards oldest queued message; favors recency over completeness
  • discard_new — Drops incoming message; preserves queued work

Factory functions in [include/spdlog/async.h](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/async.h#L31-L70) configure these policies:

#include <spdlog/async.h>

// Blocking (default) - may stall caller
auto logger = spdlog::create_async<spdlog::sinks::basic_file_sink_mt>(
    "async_block", "app.log");

// Non-blocking with overrun_oldest policy
auto nb_logger = spdlog::create_async_nb<spdlog::sinks::stdout_color_sink_mt>(
    "async_nb", std::cout);

Ordering and Thread Safety

Synchronous loggers provide strict ordering per sink — messages arrive at each sink in exact emission order because no concurrency exists between log calls and sink writes.

Asynchronous loggers preserve FIFO ordering per logger — the background worker processes each logger's queue sequentially. When multiple async loggers share a thread pool (the default global pool), their relative ordering is non-deterministic based on dequeue scheduling.

Both implementations are thread-safe for concurrent log calls. The synchronous logger uses mutex protection around sink iteration; the async logger uses lock-free queue operations.

Code Examples: Choosing Your Logger

High-Throughput Async Pattern

#include <spdlog/async.h>
#include <spdlog/sinks/daily_file_sink.h>

int main() {
    // 4MB queue, 2 background threads
    spdlog::init_thread_pool(8192, 2);
    
    auto daily = spdlog::create_async<spdlog::sinks::daily_file_sink_mt>(
        "daily_async", "logs/daily.log", 2, 30);
    
    // Thousands of calls/sec, minimal caller impact
    for (int i = 0; i < 1000000; ++i) {
        daily->info("Event {}", i);
    }
}

Latency-Critical Sync Pattern

#include <spdlog/spdlog.h>
#include <spdlog/sinks/null_sink.h>

int main() {
    // Null sink for absolute minimal latency benchmarking
    auto null_logger = spdlog::create<spdlog::sinks::null_sink_mt>("null");
    
    // ~50-100ns per call, no allocation, no queue
    for (int i = 0; i < 1000000; ++i) {
        null_logger->trace("Benchmark {}", i);
    }
}

Configuration and Factory Methods

Logger Type Factory Function Header Thread Pool
Synchronous, multi-thread spdlog::basic_logger_mt() spdlog/spdlog.h None
Synchronous, single-thread spdlog::basic_logger_st() spdlog/spdlog.h None
Asynchronous, blocking spdlog::create_async<>() spdlog/async.h Global default
Asynchronous, non-blocking spdlog::create_async_nb<>() spdlog/async.h Global default

The global thread pool initializes lazily on first async logger creation with default parameters (8192 queue slots, 1 thread). Explicit initialization via spdlog::init_thread_pool(queue_size, thread_count) allows customization before logger creation.

Summary

  • Synchronous loggers (spdlog::logger) execute complete log processing in the caller thread, providing predictable latency and strict ordering with minimal memory overhead — ideal for low-volume logging or latency-sensitive debug builds.

  • Asynchronous loggers (spdlog::async_logger) enqueue messages to a lock-free queue processed by background threads, decoupling application performance from I/O speed — essential for high-throughput production systems.

  • Performance tradeoffs center on caller latency versus memory usage, with overflow policies (block, overrun_oldest, discard_new) offering tunable guarantees under load.

Frequently Asked Questions

How much faster is asynchronous logging in spdlog?

Benchmarks vary by hardware and sink type, but typical async logger queue insertion costs 100-500 nanoseconds versus 1-10 microseconds for file sinks or 100+ microseconds for network sinks synchronously. The caller latency reduction often exceeds 10x for I/O-bound scenarios. Actual throughput gains depend on queue depth configuration and burst patterns.

When should I use synchronous loggers instead of async?

Choose synchronous loggers when: logging volume is low and predictable; you require strict crash durability (no queued messages lost on process termination); debugging with guaranteed immediate output; or operating in memory-constrained embedded environments where queue allocation is undesirable.

Can async loggers lose messages?

Yes, depending on overflow policy. The block policy guarantees delivery but may stall producers. overrun_oldest and discard_new explicitly drop messages to maintain throughput. Additionally, unclean process termination loses any messages still in the queue before background threads flush them.

Do async loggers preserve message ordering across multiple loggers?

No — ordering is per-logger only. When multiple async_logger instances share the global thread pool, their messages interleave non-deterministically based on queue dequeue timing. For global ordering, use a single async logger or implement custom sequencing in your application.

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 →