Event-driven realtime delivery over Server-Sent Events (SSE), powered by Redis.
eventrail is a Go library for building scalable real-time event delivery using Server-Sent Events (SSE).
It is designed for horizontal scalability, works naturally with server-side rendering (e.g. htmx),
and keeps the backend as the single source of truth.
The library focuses on event invalidation, not state replication.
- No polling
- No SPA complexity
- No sticky sessions
- No shared in-memory state between instances
- Works well with SSR + htmx-style partial refresh
CRUD → Redis → SSE → Browser → Fragment refresh
eventrail does not push state or diffs to the client.
Instead:
- The backend emits a lightweight event
- Browsers receive the event via SSE
- The frontend decides what to refresh (HTML fragments, tables, dashboards)
This model is robust, scalable, and easy to reason about.
flowchart LR
Browser -->|SSE| LoadBalancer
LoadBalancer --> API1[API Instance #1]
LoadBalancer --> API2[API Instance #2]
LoadBalancer --> API3[API Instance #N]
API1 --> Redis[(Redis)]
API2 --> Redis
API3 --> Redis
Key properties:
- Each API instance manages its own SSE connections
- Redis is the only coordination point
- No instance-to-instance communication
- No sticky sessions required
sequenceDiagram
participant UserA as User A
participant API as API Instance
participant Redis as Redis
participant BrowserB as Browser B
UserA->>API: Update student
API->>Redis: Publish gym:1:students
Redis-->>API: Fan-out event
API-->>BrowserB: SSE event students.changed
API-->>UserA: SSE event stundents.changed
BrowserB->>API: htmx GET /students/fragment
UserA->>API: htmx GET /students/fragment
All Redis channels must follow:
{scope}:{scope_id}:{topic}
Examples:
gym:1:students
org:42:plans
tenant:9:payments
scope: tenant type (gym, org, tenant, etc.)scope_id: tenant identifiertopic: affected domain or aggregate
Never publish global channels.
{EventNamePrefix}.{topic}.{action}
Examples:
app.students.changed
app.plans.created
If EventNamePrefix is empty, the event name is just {topic}.{action}.
{
"event_type": "students.changed",
"data": {
"id": 123
}
}go get github.com/PabloPavan/eventrail@latestrdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
})
// sseredis is github.com/PabloPavan/eventrail/sse/redis
broker := sseredis.NewBrokerPubSub(rdb)
server, err := sse.NewServer(broker, sse.Options{
Resolver: myResolver,
Router: func(p *sse.Principal) []string {
return []string{
fmt.Sprintf("gym:%d:*", p.UserID),
}
},
EventNamePrefix: "app",
})
if err != nil {
panic(err)
}If you don't want Redis (single-process only):
// ssememory is github.com/PabloPavan/eventrail/sse/memory
broker := ssememory.NewBrokerInMemory()r := chi.NewRouter()
r.Get("/events", func(w http.ResponseWriter, r *http.Request) {
server.Handler().ServeHTTP(w, r)
})Use a base context to tie broker subscriptions to your app lifecycle, then call Close() when shutting down.
Active SSE handlers also stop when this context is canceled.
ctx, cancel := context.WithCancel(context.Background())
server, err := sse.NewServer(broker, sse.Options{
Context: ctx,
Resolver: myResolver,
Router: myRouter,
})
if err != nil {
panic(err)
}
// On shutdown:
cancel()
server.Close()pub := server.Publisher()
channel := fmt.Sprintf("gym:%d:students", gymID)
_ = pub.PublishEvent(ctx, channel, sse.Event{
EventType: "students.changed",
Data: json.RawMessage(`{"id":123}`),
})<div id="students-list"
hx-get="/students/fragment"
hx-trigger="load, refresh"
hx-swap="outerHTML">
</div>
<script>
const es = new EventSource("/events");
es.addEventListener("app.students.changed", () => {
htmx.trigger("#students-list", "refresh");
});
</script>Use a hidden origin_id generated by the server so the tab that sent the POST can ignore its own SSE event.
<input type="hidden" id="origin-id" name="origin_id" value="{{.OriginID}}">
<form hx-post="/students"
hx-include="#origin-id"
hx-swap="none">
...
</form>
<script>
const originId = document.querySelector("#origin-id").value;
const es = new EventSource("/events");
es.addEventListener("app.students.changed", (evt) => {
const data = JSON.parse(evt.data);
if (data.origin_id === originId) return;
htmx.trigger("#students-list", "refresh");
});
</script>Backend: read origin_id from the POST and include it in the event payload (data.origin_id).
func createStudent(w http.ResponseWriter, r *http.Request) {
originID := r.FormValue("origin_id")
// ... create the student ...
payload, _ := json.Marshal(map[string]any{
"id": studentID,
"origin_id": originID,
})
_ = pub.PublishEvent(r.Context(), channel, sse.Event{
EventType: "students.changed",
Data: json.RawMessage(payload),
})
}examples/basic: runnable SSE server with in-memory broker.
eventrail is authentication-agnostic.
You provide a PrincipalResolver:
type PrincipalResolver interface {
Resolve(r *http.Request) (*Principal, error)
}The resolver defines:
- who the caller is
- which scope they belong to
- One SSE connection per browser
- One Redis subscription per scope per instance
- Linear horizontal scalability
- Works behind standard HTTP load balancers
Contributions are welcome. By submitting a PR, you agree that your work is licensed under the MIT license and may be modified or rejected by maintainers. Please keep changes focused, include tests where it makes sense, and run the checks in the Development section.
Run checks locally:
go vet ./...
golangci-lint runOr via Makefile:
make vet
make lint
make checkMIT