I wanted a product to feel reactive without turning notifications into a platform of their own. The system needed three things: storage that kept notifications until a user could read them, a signal that woke connected users when something changed, and a transport that exposed both to a client.
That separation was useful. A standalone service was not.
I did not have hundreds of thousands of active users or enough notification traffic to justify operating a standalone service. I still wanted the product to feel reactive: write something once, let a connected interface update immediately, and allow someone who was offline to find the same notification later.
Then I revisited PostgreSQL's native LISTEN and NOTIFY commands. The database already had the signal primitive I needed: it could remain the durable store, emit a small message after a successful write, and wake a Go process without polling or introducing another service.
The resulting system is intentionally small:
- The application inserts a notification row.
- An
AFTER INSERT trigger calls pg_notify().
- PostgreSQL delivers that signal when the transaction commits.
- One dedicated Go connection listens for it.
- An in-process hub wakes subscribers for the affected user.
- The client reads the durable notification from PostgreSQL.
It is closer to a reactive application than a message broker.
Store the fact, signal the change
The most important decision was not putting the whole notification system inside the NOTIFY payload. PostgreSQL notifications are ephemeral and deliberately small. The table stores the notification; the message only says which stored fact changed.
The application still performs one ordinary insert. It does not insert and then separately publish to Redis, call a WebSocket manager, or hope a second network operation succeeds.
An AFTER trigger runs within the transaction, while PostgreSQL waits until commit to deliver the notification. A rolled-back insert therefore produces neither a visible row nor a signal telling the interface to fetch one.
This is the useful “success layer”: once a listener receives the message, it can assume the referenced row committed.
LISTEN owns one database connection
LISTEN is session-scoped, so it should not run through the ordinary query pool. The listener owns one connection for its entire lifetime and uses it for no unrelated work.
This listener cannot use transaction pooling. A pooler may move ordinary queries between backend sessions, but a LISTEN subscription belongs to the specific session that created it. If that connection closes, the process must reconnect and subscribe again.
A small Go hub is enough
The in-process hub does not store notification history. PostgreSQL already does that. It only keeps the channels belonging to currently connected users and removes them when those users disconnect.
This is where my design differs from keeping a large buffered channel for every possible user. Memory grows with active subscriptions, not with the total number of accounts. The buffer is only a wake-up slot, not a second notification database.
Dropping a signal for a slow subscriber is acceptable because the signal carries no unique state. The client can query unread rows and converge on the same result.
Join the snapshot before the live tail
A reactive endpoint needs both history and live changes. Querying first and subscribing second leaves a gap: a notification can commit between those operations and never reach the open connection.
The safer order is to subscribe first, read the current rows second, then begin forwarding signals:
The transport can be long polling or Server-Sent Events when the client wants a continuous one-way stream. The storage and signal boundaries do not change.
Where the lightweight version stops
PostgreSQL LISTEN/NOTIFY is not a queue. Notifications are not replayable, there is no acknowledgement, and a listener that is offline at commit time misses the signal. Payloads must also stay below PostgreSQL's limit, which is another reason to publish an ID rather than a full record.
For in-app notifications, the table makes those constraints manageable. A reconnect reads unread rows, so a missing signal delays the interface but does not erase the notification.
Email is different. Once sending mail became part of the design, I needed work that could survive a provider outage and retry without an open browser or database listener. That path eventually belonged in Redis Streams, where a worker could acknowledge completion, reclaim abandoned messages, and dead-letter repeated failures.
That is a different article. The useful lesson from this smaller version is that “reactive” does not automatically mean Kafka, a dedicated notification service, or even Redis. If the durable state is already in PostgreSQL, LISTEN/NOTIFY can be the small signal that connects a committed row to a live Go application.
If events became a broader coordination layer inside the process, I would take cues from Tailscale's event bus. It connects typed publishers and subscribers, gives clients an ordered view of published events, and includes tooling for inspecting the bus. That machinery makes sense when many components communicate through events and ordering becomes part of the contract. Here, each signal only invalidates one user's database-backed view. A small keyed hub was easier to operate and easier to explain, and staying lean felt like the right boundary.