A minimal, in-process event bus for Go β typed events, cancelable listeners, and one-shot handlers, built on top of the zero-allocation go-pubsub engine.
π go-bus is in-process and fire-and-forget. It is the right tool for decoupling components inside a single Go binary. If you need to cross a process or network boundary, reach for NATS, Kafka, or RabbitMQ instead.
| π― Typed events | Dispatch arbitrary any payloads keyed by a typed Event. |
| β‘ Zero-alloc emits | The publish hot path allocates nothing for a known event β see Performance. |
| π One-shot handlers | Listeners can auto-remove after their first trigger. |
| ποΈ Context-based cancel | Every listener owns a context.Context; cancel stops it cleanly. |
| π‘οΈ Panic-safe | A panicking listener is recovered and logged; it keeps running on later messages. |
| π Thin layer | A focused wrapper over go-pubsub β no hidden runtime, no goroutine zoo to manage. |
- Domain / application events β "user signed up", "order created" fanned out to multiple handlers within one process.
- Decoupling producers from consumers β emit without knowing who listens; react without importing the emitter.
- One-shot hooks β "run this once on first connection", then auto-remove.
- Lightweight fan-out β notify N subscribers per emit, allocation-free on the hot path.
- Cross-process / distributed pub-sub β go-bus has no network protocol. Use NATS, Kafka, or RabbitMQ.
- Durable queues or at-least-once delivery β a message lives only inside a subscriber's buffered channel. There is no persistence and no redelivery.
- Strict reliability β if a subscriber falls behind and its channel fills, messages are dropped silently. This is fire-and-forget by design.
- Backpressure / acknowledgement β
Emitnever blocks and there is no ack path. Successful return means only "the broker accepted the message".
go get github.com/F2077/go-busRequires Go 1.25+ (the underlying go-pubsub uses the tool directive).
package main
import (
"fmt"
"log/slog"
"os"
"time"
"github.com/F2077/go-bus/bus"
)
func main() {
// 1. Create a bus (optionally inject your own logger).
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
eventBus, err := bus.NewBus(bus.WithLogger(logger))
if err != nil {
logger.Error("failed to create bus", "error", err)
return
}
// 2. Define events as typed constants.
const (
ConfigReloaded = iota + 1
ThresholdBreached
)
reload := bus.NewEvent(ConfigReloaded)
// 3. Register a listener.
listener := bus.NewListener(func(msg any) {
fmt.Printf("config reloaded: %#v\n", msg)
})
if err := eventBus.On(reload, listener); err != nil {
logger.Error("failed to subscribe", "error", err)
return
}
// 4. Emit an event.
if err := eventBus.Emit(reload, "new.yaml"); err != nil {
logger.Error("failed to emit", "error", err)
}
// 5. Give the listener a moment, then stop it.
time.Sleep(100 * time.Millisecond)
listener.Cancel()
fmt.Println("demo complete")
}A runnable version of the above lives at cmd/quickstart.
An Event is a named integer. Use iota constants to keep them tidy:
const (
ConfigReloaded = iota + 1
ThresholdBreached
ConnectionDropped
)
event := bus.NewEvent(ConfigReloaded)NewListener takes a callback (func(msg any)) and optional options.
On registers it; the returned listener can be Canceled at any time.
listener := bus.NewListener(func(msg any) {
// handle msg
})
_ = eventBus.On(bus.NewEvent(ThresholdBreached), listener)
// ...later, stop receiving:
listener.Cancel()Pass WithOnetime(true) for a handler that fires exactly once and then
auto-removes β handy for "do this on the first occurrence":
once := bus.NewListener(func(msg any) {
fmt.Println("first connection dropped β paging on-call")
}, bus.WithOnetime(true))
_ = eventBus.On(bus.NewEvent(ConnectionDropped), once)Emit is non-blocking and never panics on a slow consumer β it dispatches to
every current listener on that event. The publish path is allocation-free for a
known event; the first emit of a brand-new event value costs one topic slot.
_ = eventBus.Emit(bus.NewEvent(ThresholdBreached), "cpu 94%")By default go-bus writes to a quiet slog text handler at Error level (this
is where recovered listener panics land). Inject your own:
eventBus, _ := bus.NewBus(bus.WithLogger(myLogger))Every listener runs in its own goroutine until canceled. Stop one explicitly, or tear down the whole bus:
listener.Cancel() // stop one listener
eventBus.Close() // stop every listener at onceIf Cancel (or Close) lands between a message leaving the channel and its
callback, that message is discarded so cancellation takes effect at once β a
one-shot listener can therefore fire zero times if its first message races with
cancellation.
Each distinct Event value becomes a broker topic. The broker holds at most
8192 topics by default; Emit to a brand-new event beyond that cap returns a
wrapped ErrSubscriptionCapacityExceeded. Keep Event as a small set of
semantic constants and carry high-cardinality data (user IDs, timestamps) in
the payload. Raise the cap when you must:
bus.NewBus(bus.WithCapacity(65536))go-bus is a thin layer over go-pubsub. The delivery model is deliberately simple:
- Fan-out, not queueing. One
Emitdelivers to every listener currently subscribed to that event. - Buffered, drop-on-full. Each listener drains a buffered channel. If the channel is full when a message arrives, that message is dropped for that listener β there is no backpressure and no redelivery.
- Per-listener goroutine.
Onstarts one goroutine that runs the callback until the listener is canceled, becomes one-shot and fires, or the bus's subscription channel closes. - Panic isolation. A panicking callback is recovered and logged through the bus logger; siblings and the bus itself are unaffected.
For finer-grained control (per-topic channel sizing, sliding idle timeouts, introspection), use go-pubsub directly.
Measured with go test -bench=. -benchmem -benchtime=1s on an Intel Core Ultra 5 125H
machine, Go 1.25. Numbers are indicative β treat them as relative, not absolute.
| Benchmark | Setup | ns/op | B/op | allocs/op |
|---|---|---|---|---|
BenchmarkEventBus |
1000 listeners, parallel emit | 227 | 0 | 0 |
BenchmarkFanout/subs=1 |
1 listener | 221 | 0 | 0 |
BenchmarkFanout/subs=10 |
10 listeners | 1,373 | 0 | 0 |
BenchmarkFanout/subs=100 |
100 listeners | 4,644 | 0 | 0 |
BenchmarkFanout/subs=1000 |
1000 listeners | 36,448 | 10 | 0 |
BenchmarkOneTimeListener |
On + Emit + auto-remove | 6,595 | 4,142 | 20 |
Takeaways
- The emit hot path is zero-allocation up to 1000 subscribers β the reusable
publisher plus
go-pubsub'ssync.Poolfan-out snapshot keep it clean. - Fan-out cost scales ~linearly with subscriber count, as expected for a per-subscriber delivery loop.
OneTimeListeneris the only allocating path, because each registration builds aListener, acontext.Context, and a fresh subscriber handle.
Reproduce locally:
go test -run='^$' -bench=. -benchmem -benchtime=1s ./bus/