ringbuf is a high-performance, generic, concurrent ring buffer. It enables thousands of consumers to independently read from a live stream of data with minimal synchronization and zero-allocation reads. Designed for high-throughput scenarios where readers are disposable and best-effort delivery is acceptable.
- Features
- Use Cases
- Quick Start
- Design Philosophy
- Performance Characteristics
- Benchmarks
- Examples
- Authors
- License
- Single-writer, multi-reader fan-out — one producer with thousands of independent consumers
- Lossy, best-effort delivery — optimized for real-time streams where readers may fall behind
- Lock-free hot paths — atomic writes and reads for ultra-low latency
- Zero-allocation reads —
io.Reader-style API with caller-managed buffers - Idiomatic iteration — blocking
iter.Seqfor cleanfor rangeconsumption - Independent subscribers — each reader maintains its own cursor and lag tolerance
- Built for scale — efficiently handles 10,000+ concurrent readers
ringbuf is ideal for high-throughput, low-latency, in-memory streaming where readers are disposable and delivery is best-effort. It is not intended for durable queues, guaranteed delivery, or backpressure-driven systems.
Typical use cases include:
- Fan-out distribution — replace Go channels for one-to-many data delivery
- In-memory pub/sub — lightweight real-time event streaming
- High-frequency trading — ultra-low latency market data fan-out
- Metrics aggregation — distributing high-frequency metrics to multiple consumers
- Data pipelines — buffering and fan-out between asynchronous pipeline stages
- Log tailing — in-memory
tail -fwith multiple concurrent readers
go get github.com/golang-cz/ringbufpackage main
import (
"context"
"errors"
"fmt"
"io"
"log"
"time"
"github.com/golang-cz/ringbuf"
)
func main() {
stream := ringbuf.New[string](1000)
// Single writer (producer)
go func() {
for i := range 10_000 {
stream.Write(fmt.Sprintf("event-%d", i))
time.Sleep(100 * time.Microsecond) // Simulate i/o latency.
}
stream.Close() // Broadcast io.EOF (end of stream).
}()
// Subscriber (consumer)
sub := stream.Subscribe(context.TODO(), nil)
for event := range sub.Iter() {
fmt.Println(event)
}
if err := sub.Err(); !errors.Is(err, io.EOF) {
log.Fatal(err)
}
}ringbuf is designed for high-throughput, real-time fan-out with a single producer and many independent consumers. The primary goal is to maximize write and read performance while keeping synchronization overhead close to zero.
This design intentionally favors:
- Throughput over durability
- Writer progress over slow readers
- Simplicity over generality
Key trade-offs:
- Lossy by design — readers that fall behind are terminated
- No backpressure — the producer never blocks on consumers
- Single writer — enables lock-free writes and predictable performance
- Blocking readers — subscribers wait efficiently for new data
- Best-effort delivery — suitable for live streams, not durable messaging
If you need guaranteed delivery, persistence, replay, or backpressure, this is not the right abstraction.
- Write path: Lock-free using atomic operations (~5 ns/op)
- Read path: Lock-free hot path with minimal synchronization when waiting for new data
- Memory: No memory allocations during data reads (0 B/op)
- Scalability: Optimized for thousands of concurrent subscribers (1-100,000 in-memory readers)
- Latency: Sub-microsecond read/write operations in common scenarios
- Write-throughput: 200M+ writes/sec on modern hardware
- Read-throughput: 200B+ in-memory reads/sec on modern hardware
Performance heavily depends on hardware, ring buffer size and subscriber configuration (e.g. MaxLag). In real-world use cases, subscribers will likely be limited by I/O, network, message encoding/decoding, and also by other CPU and Go scheduler overhead of your program.
- Batched writes generally perform better, as they wake subscribers less often.
- The bigger the MaxLag and ring buffer size, the more concurrent readers will be able to keep up with the writer's pace (e.g. survive burst writes or allow less reliable/slow network connections).
- With a sufficiently large buffer, it's often OK to allow subscribers to lag behind the head by up to ~90% of the buffer size.
- We strongly advise users to tune their configuration based on testing.
This repository comes with an in-memory throughput benchmark test. See the following results on MacBook M5.
Here we rate-limit the writer to ~1,000 Write() calls/sec; and each write batches 100 messages, that is ~100,000 uint64 messages/sec in total. We allow readers to read a batch of up to 100 messages at a time:
$ go test -bench=BenchmarkThroughput -run=^$ -buffer_size=200000 -subscribers=1,10,100,1_000,10_000,50_000,100_000,200_000,500_000,1_000_000 -write_rate=1000 -write_batch=100 -read_batch=100 .
goos: darwin
goarch: arm64
pkg: github.com/golang-cz/ringbuf
cpu: Apple M5
BenchmarkThroughput/subscribers_1-10 100072 reads/s 100072 writes/s 0 errors
BenchmarkThroughput/subscribers_10-10 1000722 reads/s 100072 writes/s 0 errors
BenchmarkThroughput/subscribers_100-10 10007106 reads/s 100071 writes/s 0 errors
BenchmarkThroughput/subscribers_1000-10 100075501 reads/s 100076 writes/s 0 errors
BenchmarkThroughput/subscribers_10000-10 1000686559 reads/s 100069 writes/s 0 errors
BenchmarkThroughput/subscribers_50000-10 5003855243 reads/s 100077 writes/s 0 errors
BenchmarkThroughput/subscribers_100000-10 10004740196 reads/s 100047 writes/s 0 errors
BenchmarkThroughput/subscribers_200000-10 20010092634 reads/s 100050 writes/s 0 errors
BenchmarkThroughput/subscribers_500000-10 280261997 reads/s 560.5 writes/s 0 errors
BenchmarkThroughput/subscribers_1000000-10 270855033 reads/s 270.9 writes/s 0 errors
PASS
ok github.com/golang-cz/ringbuf 74.387s
We can see that up to 200,000 subscribers were able to keep up with the writer and read a total of ~20 billion messages/sec with no subscriber falling behind (errors=0). However, at 1,000,000 subscribers, we can see that the system was overloaded and the overall throughput degraded. The buffer size was very generous (200,000 items) and exceeded the number of writes, so we didn't see any errors.
However, when we decrease the buffer size from 200,000 to just 10,000 items:
$ go test -bench=BenchmarkThroughput -run=^$ -buffer_size=10000 -subscribers=1,10,100,1_000,10_000,50_000,100_000,200_000,500_000,1_000_000 -write_rate=1000 -write_batch=100 -read_batch=100 .
goos: darwin
goarch: arm64
pkg: github.com/golang-cz/ringbuf
cpu: Apple M5
BenchmarkThroughput/subscribers_1-10 100072 reads/s 100072 writes/s 0 errors
BenchmarkThroughput/subscribers_10-10 1000719 reads/s 100072 writes/s 0 errors
BenchmarkThroughput/subscribers_100-10 10007222 reads/s 100072 writes/s 0 errors
BenchmarkThroughput/subscribers_1000-10 100071101 reads/s 100071 writes/s 0 errors
BenchmarkThroughput/subscribers_10000-10 1000782188 reads/s 100078 writes/s 0 errors
BenchmarkThroughput/subscribers_50000-10 1923639298 reads/s 100077 writes/s 32403 errors
BenchmarkThroughput/subscribers_100000-10 419123458 reads/s 100065 writes/s 97163 errors
BenchmarkThroughput/subscribers_200000-10 138727297 reads/s 100072 writes/s 199996 errors
BenchmarkThroughput/subscribers_500000-10 324328784 reads/s 648.7 writes/s 0 errors
BenchmarkThroughput/subscribers_1000000-10 164544105 reads/s 164.5 writes/s 0 errors
PASS
ok github.com/golang-cz/ringbuf 56.813s
We can see that we were able to handle up to 10,000 subscribers with maximum throughput and no errors. However, at 50,000 subscribers, we start seeing errors (subscribers falling behind). We can see that our total read throughput peaked at ~19 billion messages/sec.
Write batches to reduce wakeups (and amortize overhead):
// Producer: batch write.
events := []string{"event-1", "event-2", "event-3"}
stream.Write(events...)Read in a loop into a caller-managed slice (0 allocations on the hot path):
// Consumer: batch read.
sub := stream.Subscribe(ctx, nil)
events := make([]string, 100)
for {
n, err := sub.Read(events)
if err != nil {
// err is typically io.EOF (end of stream), ringbuf.ErrTooSlow, or ctx error.
break
}
for i := range n {
handle(events[i])
}
}// Subscribe to historical data (e.g. last 100 items)
sub := stream.Subscribe(ctx, &ringbuf.SubscribeOpts{
Name: "historical-reader",
StartBehind: 100, // Start reading from 100 items ago, if available
MaxLag: 500, // Allow up to 500 items of lag
})// Subscribe to latest (future) data only
sub := stream.Subscribe(ctx, &ringbuf.SubscribeOpts{
Name: "latest-reader",
StartBehind: 0, // Start from the latest position
MaxLag: 100, // Allow up to 100 items of lag
})The Seek() method allows subscribers to fast-forward to a specific position efficiently without locking
(it uses atomic loads of the writer cursor and reads already-written items from the buffer).
type Message struct {
ID int64
Data string
}
// Example: Subscriber reconnects to a stream with the last processed message ID
func reconnectExample(ctx context.Context, stream *ringbuf.RingBuffer[Message], lastMsgID int64) {
sub := stream.Subscribe(ctx, &ringbuf.SubscribeOpts{
Name: "reconnect-subscriber",
StartBehind: stream.Size() * 3 / 4, // Start from 75% back in the buffer.
MaxLag: stream.Size() * 3 / 4, // Allow up to 75% lag.
})
// Seek to the last processed message and resume right after it.
found := sub.SeekAfter(func(msg Message) int {
return cmp.Compare(msg.ID, lastMsgID)
})
if !found {
fmt.Printf("Failed to resume by last message ID %d (not in buffer)", lastMsgID)
return
}
// Resume processing.
for msg := range sub.Iter() {
fmt.Printf("Processing message %d: %s\n", msg.ID, msg.Data)
}
if sub.Err() != nil {
fmt.Printf("Subscriber error: %v\n", sub.Err())
}
}