7/12/2026 • 17 min read
Keeping Write Paths Fast with Batching and Aggregation in Go
Fast request paths often become slow for an unglamorous reason: every event triggers its own write. One log record, one analytics event, or one view increment may be cheap alone. At sustained traffic, database round trips, lock contention, queue coordination, and allocation pressure turn that work into latency and backend load.
gbuffer is a small Go library built around one goal: accept typed work cheaply now, then flush it efficiently later.
More project details: iam.foxie.vip.
Problem: Per-Event Writes Do Not Scale Cleanly
Consider a video view counter:
This preserves a simple mental model, but makes storage part of every request. For logs and audit events, writing each item separately also increases network calls and transaction overhead. For counters, repeated updates to one key produce redundant work:
Writing those three updates independently costs three storage operations. The useful result is often only this:
The fix is not "put everything in a queue." Different workloads need different shapes, bounded memory, explicit shutdown, and predictable scheduling.
Solution: Buffer Before Writing
gbuffer has two typed buffering paths:
| Workload | Buffer | Flush payload |
|---|---|---|
| Logs, orders, audit records | Batcher[T] | []T |
| Views, likes, impressions, inventory deltas | Aggregator[K, V] | map[K]V |
Both paths flush to a Sinker[T]:
The sink owns persistence. The buffer owns when and how pending values become one payload.
Batch Independent Records
Use a batcher for values that must all be preserved but can be written together.
Add appends to pending memory. Flush happens at configured size or interval. One sink call can persist many records.
Aggregate Repeated Updates
Use an aggregator when updates to same key can be combined before persistence.
Before flush, repeated values merge in memory:
This removes redundant writes while keeping merge logic explicit through a Combiner[V].
Share Scheduling, Keep Types
One application may buffer logs, orders, and view counters. Creating separate worker pools for every type fragments concurrency limits and makes priority hard to control.
gbuffer wraps each typed flush payload in a non-generic Job. Typed buffers can then share one priority pool:
This separates responsibilities:
The request path does not need to know database batch syntax or worker scheduling details.
Middleware: Input and Output Hooks
Batch and aggregate operations benefit from optional behavior at two layers: before values enter the buffer (add middleware) and before flushed payloads are written (sink middleware).
| Layer | When it runs | Example uses |
|---|---|---|
| Add middleware | Per input value, before buffer | Validate, skip, deduplicate, sample, reject |
| Sink middleware | Per flushed payload, before write | Retry, metrics, tracing, circuit breaker, panic recovery |
Add middleware wraps the Add call:
Use it to skip or transform values before they enter memory:
Sink middleware wraps the Sinker:
Use it for cross-cutting payload-level behavior:
For aggregators, the same pattern applies with map payloads:
Middleware order is predictable: UseSink(A, B, C) runs A -> B -> C -> sink. Errors remain inspectable: ErrFull, ErrClosed, ErrDropped, or context.Canceled.
Keep add middleware for per-value decisions and sink middleware for payload durability and observability. This keeps the fast path clean while allowing production hardening without changing core buffer configuration.
Performance Baseline
Local benchmarks on Apple M1, macOS arm64, measure core in-memory paths:
These are baselines, not production guarantees. Hardware, payload shape, contention, flush thresholds, and sink latency all change real-world results.
The comparison still explains design tradeoffs:
Batcher.Addis cheapest: mutex-protected append until a threshold flushes.Aggregator.Addcosts more: map access plus value combination.WorkerPool.Submitcosts more again: priority heap work and worker coordination.
The hot path stays small by avoiding copies until flush, rotating pending storage rather than copying each input, and keeping Redis, Kafka, retries, and observability outside core execution.
Run benchmarks locally before changing behavior:
Bounded Memory and Shutdown Matter
Buffering trades immediate I/O for memory. That trade needs limits.
gbuffer keeps pending data bounded and returns ErrFull when an aggregator cannot accept another distinct key. It also exposes explicit lifecycle methods:
Close stops new values, flushes pending data, submits remaining jobs, and waits until completion or context expiry. Future Add calls return ErrClosed.
For workloads that need stronger durability, combine memory with a spill store. When the in-memory aggregator reaches its key threshold, raw increments spill to Redis instead of returning ErrFull:
The consumer can replay spilled keys and merge them into the next aggregation cycle. Same pattern applies for batcher overflow — spill individual values when memory fills, then drain on recovery.
When to Use Each Durability Level
| Workload | Recommended setup | Why |
|---|---|---|
| Logs, analytics events | Memory with drop or spill | Brief data loss during restarts is acceptable; keep hot path cheapest |
| Metrics, counters | Memory with drop | Repeated increments merge anyway; a few lost ticks do not matter |
| Video views, impressions | Aggregator with flush-early + optional Redis spill | Coalescing reduces write volume; spill handles traffic bursts without blocking |
| Orders, payments | Durable before ack | Every transaction must survive process restart before response |
| Audit events | Memory with shutdown drain + durable spill | Normal writes use memory; graceful close flushes; hard crash spills remaining data |
| Inventory deltas | Memory with spill | Losing a delta corrupts stock; spill removes the memory-bound risk |
Code: Memory Only
Lowest latency, lossy on restart. Good for logs, analytics, metrics.
Code: Memory + Redis Spill
Fast memory path until full, then spills to Redis. Good for views, impressions, inventory.
Code: Durable Before Ack (Redis Only)
Every write persists before returning. No data loss. Good for orders, payments.
Cost Comparison Per 1M Events
| Workload | Add latency (p50) | Memory per 1K items | Write amplification | Data loss on crash |
|---|---|---|---|---|
| Memory batcher, drop | ~14 ns | ~8 B per item | 500:1 (batch size) | Full pending batch |
| Memory aggregator, drop | ~51 ns | ~72 B per key + value | 1000:1 (event threshold) | Full pending map |
| Memory batcher + Redis spill | ~200 µs per spill | ~8 B per item | 500:1 | Spilled values survive |
| Memory aggregator + Redis spill | ~210 µs per spill | ~72 B per key | 1000:1 | Spilled keys survive |
| Durable before ack (Redis) | ~1-3 ms | ~0 B (no buffer) | 1:1 | None |
Interpretation:
- Memory-only paths are sub-microsecond per event. Redis or disk I/O dominates when spills happen.
- Durable before ack adds 2–3 orders of magnitude latency but guarantees zero data loss.
- Spill-only-on-full gives the best of both: most events stay in the fast memory path; only burst traffic pays the I/O cost.
- For aggregators, spilling raw increments is cheaper than spilling merged values because the downstream consumer can re-aggregate without coordination.
Durable overflow to Redis, Kafka, or disk is intentionally an extension pattern, not a hidden core dependency. Add it when in-memory bounds are not enough for delivery requirements.
When to Use It
Use gbuffer when writes can tolerate controlled delay and benefit from batching or coalescing. Do not use it for operations that require each request to synchronously confirm durable storage before responding.
Start with measured bottlenecks. Add batching for independent records. Add aggregation only where multiple updates safely merge. Keep thresholds and flush intervals tied to latency and memory budgets.
Links
Other posts that might interest you...
Concurrency in Go
Learn the basics and advanced concepts of goroutines, channels, and concurrent programming in Go.
Understanding Go Runtime Internals
Take a deep dive into the architecture and components of the Go runtime and compiler.
System Design Fundamentals
Explore the core principles and patterns of system design for scalable, reliable applications.