Async Logging: Non-Blocking Logging for High-Throughput Applications
In this tutorial, you will learn about Async Logging: Non. We cover key concepts, practical examples, and best practices to help you master this topic.
Async logging ensures that log writes do not block application execution. In high-throughput applications, synchronous logging to disk or network can add significant latency. Async logging uses buffers, background threads, or non-blocking I/O to minimize the performance impact of logging.
flowchart LR
subgraph Sync Logging
App[Application] -->|Write| FS[File System]
App -->|Wait for I/O| Blocked[App Blocked]
end
subgraph Async Logging
App2[Application] -->|Enqueue| Buffer[Memory Buffer]
Buffer -->|Background Flush| FS2[File System / Network]
App2 -->|Continue| Running[App Continues]
end
What You'll Learn
- Synchronous vs. async logging performance
- Buffered logging with periodic flush
- Pino: fastest Node.js logger (low overhead, async)
- Zero-allocation logging patterns
Why It Matters
Synchronous logging can add 100-1000 microseconds to each request. For an API serving 1000 requests/second with 10 log entries per request, that is 1-10 seconds of logging overhead per second. Async logging reduces this to near zero.
Real-World Use
A real-time analytics platform processes 50,000 events/second. Using Pino (async, low-overhead logger), each event emits 3 log entries with minimal performance impact. Switching from Winston to Pino reduced p99 latency by 40ms.
Async Logging Implementation
Pino: Fast Async Logger
const pino = require('pino');
const destination = require('pino/file');
const transport = pino.transport({
target: 'pino/file',
options: { destination: '/var/log/app.log' }
});
const logger = pino({
level: 'info',
redact: ['req.headers.authorization', 'req.body.password'],
serializers: {
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
err: pino.stdSerializers.err
}
}, transport);
// Pino benchmark comparison
async function benchmark() {
const iterations = 100000;
console.time('pino');
for (let i = 0; i < iterations; i++) {
logger.info({ iteration: i, userId: 'user_123', action: 'test' }, 'Benchmark log');
}
console.timeEnd('pino');
}
Expected output:
pino: 100000 logs in ~150ms (non-blocking, buffered I/O)
Buffered Logger with Periodic Flush
class BufferedLogger {
constructor(flushIntervalMs = 1000, maxBufferSize = 1000) {
this.buffer = [];
this.flushIntervalMs = flushIntervalMs;
this.maxBufferSize = maxBufferSize;
this.flushTimer = setInterval(() => this.flush(), flushIntervalMs);
this.flushInProgress = false;
}
log(level, message, meta = {}) {
this.buffer.push({
timestamp: new Date().toISOString(),
level,
message,
...meta
});
if (this.buffer.length >= this.maxBufferSize) {
this.flush();
}
}
async flush() {
if (this.flushInProgress || this.buffer.length === 0) return;
this.flushInProgress = true;
const batch = this.buffer.splice(0, this.buffer.length);
try {
// Write to file or send to network
if (this.writer) {
await this.writer(batch);
} else {
batch.forEach(entry => process.stdout.write(JSON.stringify(entry) + '\n'));
}
} catch (err) {
console.error('Log flush failed:', err.message);
// Re-queue on failure
this.buffer.unshift(...batch);
} finally {
this.flushInProgress = false;
}
}
shutdown() {
clearInterval(this.flushTimer);
return this.flush();
}
}
Expected output:
Logs are buffered in memory and flushed every 1000ms or 1000 entries. Flush is non-blocking.
On shutdown, buffer is flushed to prevent data loss.
Zero-Allocation Logging
const { StringDecoder } = require('string_decoder');
class ZeroAllocLogger {
constructor() {
this.buffer = Buffer.alloc(4096);
this.offset = 0;
this.decoder = new StringDecoder('utf8');
}
log(level, message) {
const timestamp = Date.now();
const entry = `${timestamp} [${level}] ${message}\n`;
const bytes = Buffer.byteLength(entry);
if (this.offset + bytes > this.buffer.length) {
process.stdout.write(this.buffer.slice(0, this.offset));
this.offset = 0;
}
this.offset += this.buffer.write(entry, this.offset);
}
flush() {
if (this.offset > 0) {
process.stdout.write(this.buffer.slice(0, this.offset));
this.offset = 0;
}
}
}
Expected output:
Zero heap allocations for log entries. Buffer writes directly to pre-allocated buffer. Minimal GC pressure.
Common Mistakes
- Using synchronous logging in high-throughput production — sync I/O blocks the event loop, increasing latency.
- Not flushing the buffer before shutdown — buffered logs that are not flushed are lost on crash or restart.
- Allocating new objects for every log entry — in hot paths, this increases GC pressure. Pre-allocate or reuse.
- Assuming async logging is always zero-cost — logging still consumes CPU for Serialization and I/O.
- Not monitoring log volume — unexpected log volume increases can slow down the application even with async logging.
Practice Questions
- Why is synchronous logging problematic for high-throughput applications?
- How does buffered logging improve performance?
- What is the difference between Pino and Winston in terms of performance?
- How do you prevent log loss with async logging?
- What is zero-allocation logging?
Challenge
Benchmark three logging approaches in Node.js: (1) Winston synchronous file logging, (2) Pino async logging, (3) custom buffered logger. Log 100,000 entries with each and measure: total time, CPU usage, and GC pressure. Report the results.
FAQ
Mini Project
Build a performance benchmarking tool for loggers. Compare: (1) console.log, (2) Winston sync, (3) Pino async, (4) custom buffered logger. Run 100,000 iterations and measure: total time, CPU, memory allocations, and GC pauses. Present results in a table.
What's Next
Continue to Log Aggregation for advanced log aggregation patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro