Vulnerability Management

Real-Time Push With Server-Sent Events in Security Tooling

By PMAP Security Team 21 min read

A vulnerability management console is only useful when it tells the truth about right now. An analyst who closes a finding wants the badge count to drop immediately. A scan that finishes at 2 a.m. should light up the scan monitor without anyone refreshing a tab. When a new critical finding lands, the notification bell should ring while the analyst is still looking at the screen, not on the next page load.

The classic way to keep a browser current is polling. The browser asks the server “anything new?” every few seconds, the server almost always answers “no,” and the cycle repeats. Polling works, but it is wasteful and it is slow. For a read-mostly security feed, there is a better fit. PMAP uses Server-Sent Events to push live updates over a single long-lived HTTP connection, scoped per tenant, with no database writes on the path.

This article walks through how that push layer works. It covers why polling falls short, what Server-Sent Events are and why they fit a security console, how a browser connects with EventSource and a JWT, how PMAP filters events per connection so one company never sees another’s activity, and what the design deliberately does not do. Everything here describes PMAP’s actual realtime transport. The deeper question of how real-time operations tie together as a product belongs to our pillar on real-time security operations.

Why Polling Falls Short for Security Dashboards

Polling is the default reflex because it is simple. The frontend sets a timer, hits an endpoint on an interval, and re-renders if the response changed. For a dashboard that watches dozens of finding lists, scan cards, and notification badges, that simplicity hides three real costs.

The first cost is latency. If you poll every ten seconds, the average update arrives five seconds late and the worst case is ten. For a status feed where an analyst is actively triaging, that lag is felt. The finding they just reassigned still shows the old owner for a few seconds, which erodes trust in the screen.

The second cost is load. Most poll requests return nothing new. Every one of them still costs a full HTTP round trip, an authentication check, and usually a database query. Multiply that by every open tab, every widget, and every analyst on shift, and a quiet platform spends most of its request budget answering “nothing changed.” That overhead grows with the team, not with the actual rate of events.

The third cost is staleness windows. Polling intervals are a compromise. Poll too often and you amplify load. Poll too rarely and the console feels dead. There is no interval that is both cheap and instant, because polling asks a question on a clock rather than reacting to a fact.

Push inverts the model. Instead of the browser asking on a timer, the server tells the browser the moment something happens. The connection stays open, sits idle when nothing is going on, and carries a frame only when there is real news. PMAP’s realtime domain is built around exactly this idea. It exists to give the frontend sub-second latency for status feeds, notification badges, and dashboard refresh without the polling tax.

What Server-Sent Events Are and Why They Fit

Server-Sent Events, usually shortened to SSE, are a browser-native way for a server to push a stream of text events to a page over one HTTP connection. The browser opens the connection once, the server keeps it open, and the server writes event frames down the pipe whenever it has something to say. The mechanism is defined in the WHATWG HTML Living Standard and exposed to JavaScript through the EventSource interface, which the MDN Server-Sent Events guide documents in detail.

The wire format is plain text. PMAP’s SSE endpoint responds with Content-Type: text/event-stream and the connection-keeping headers Cache-Control: no-cache and Connection: keep-alive. It also sets X-Accel-Buffering: no so that an nginx layer in front of the application passes frames through immediately instead of buffering them. Each event is a single line of the form data: <json>nn. The JSON payload carries its own type field inside the envelope, so the frontend can handle every event in one onmessage handler and route by event.type rather than registering a separate named listener for each kind.

What makes SSE a good fit here is the shape of the traffic. A security console is read-mostly from the realtime layer’s point of view. The browser does not need to push a stream of messages back up the same channel. It needs to receive a stream of “this changed” signals and react. SSE is built for exactly that one-directional, server-to-client flow, and it is built into the browser, so there is no extra client library to ship.

SSE vs WebSocket for a Read-Mostly Feed

WebSocket is the other common answer for live updates, and it is the right tool when you need a full duplex channel, when client and server both stream messages continuously, as in a chat app, a collaborative editor, or a multiplayer game. PMAP’s realtime layer is not that. It is a fan-out. Events originate inside the platform and flow outward to browser tabs. The browser never needs to push high-frequency messages back over the same socket.

For a fan-out feed, SSE has practical advantages. It rides on ordinary HTTP, so it passes through standard proxies and load balancers without a protocol upgrade negotiation. It is text-native and trivially debuggable with a plain HTTP client. The browser handles automatic reconnection for you. And because the realtime domain is intentionally thin, it owns only the transport. It performs the HTTP upgrade to a stream, applies per-connection scope filtering, and sends heartbeats. It does not write to the database at all. Choosing SSE keeps that thinness honest, because the layer matches the simplicity of the job it does. The bus that actually carries domain events inside the process is a separate concern, which we cover in the article on the internal event bus.

Connecting via EventSource and JWT

A browser opens the stream by pointing EventSource at a single endpoint, GET /api/v1/events. That is the only HTTP route the realtime domain exposes. There is no REST surface for managing subscriptions, because the hub is internal and every publish happens in-process. The browser connects, and from then on it receives whatever events its scope authorizes for the lifetime of the connection.

Authentication runs on a JWT, and here SSE has a quirk worth knowing. The EventSource API cannot set custom request headers, which means it cannot send the usual Authorization: Bearer <token> header that the rest of the API expects. PMAP handles this by accepting the token in either place. The endpoint first looks for an Authorization: Bearer header, and if that is absent it falls back to a ?token=<token> query parameter. That fallback is what lets a plain EventSource connect at all.

Putting a token in a URL is a tradeoff to handle carefully, because query strings tend to show up in server access logs and proxy logs in ways that headers do not. The OWASP guidance on token handling is worth reading on this point. The practical mitigations are to keep these connections over TLS, to use short-lived tokens, and to be deliberate about access-log retention for the events endpoint. PMAP supports the header path first precisely so any client that can set headers should, and only the browser EventSource case relies on the query parameter.

Once a token arrives by either path, the handler validates it the same way the rest of the platform does, through the shared JWTManager.ValidateToken. A token that fails validation never opens a stream. A token that passes becomes the basis for the connection’s scope, which is where multi-tenant isolation begins.

Role-Based Scope Filtering Per Connection

The most important thing a multi-tenant push layer has to get right is that a connection only ever receives events it is allowed to see. PMAP resolves this at connect time, once, and then carries the answer on the connection for its whole life.

The validated token tells the handler the user’s role. If the role is platform_admin, the connection is marked to receive all events regardless of company, and no database lookup is needed, because a platform admin already spans every tenant. For every other role, the handler resolves the set of companies the user has explicit access to. It calls UserScopeResolver.GetUserCompanyIDs, which is backed by the auth repository and reads the user’s user_access grants. The result is a fixed set of authorized company IDs attached to the connection.

This resolution is fail-safe by design. If the scope lookup returns an error, the handler denies the connection rather than falling back to a permissive default. There is no path where a scope-resolution failure quietly turns into “broadcast everything.” A connection either has a clearly resolved, bounded scope or it does not exist. For a platform that holds multiple companies’ vulnerability data, that fail-closed posture is the only safe choice.

Multi-Tenant Event Isolation by company_id

Scope on the connection is half the picture. The other half is scope on the event. Every event that flows through the hub carries a company_id that says which tenant it belongs to. When the hub fans an event out, it checks each connection against that field before writing a single byte.

The decision lives in a small per-connection check, client.canReceive(eventCompanyID), and it has three branches. If the connection was marked allCompanies, the event is always delivered, which is the platform-admin path. If the event’s company_id is the zero UUID, it is treated as a broadcast, a system-wide event with no single tenant owner, and it goes to everyone. Otherwise the event is delivered only when its company_id is in the connection’s authorized company set.

That third branch is the guarantee that matters. A connection scoped to Company A will never receive an event tagged for Company B, because B’s company ID is simply not in A’s authorized set, so canReceive returns false and the frame is skipped for that connection. Tenant isolation is not a filter applied somewhere upstream and hoped for. It is enforced on the hub at the moment of delivery, frame by frame. The same isolation model runs through the rest of the platform, and our pillar on real-time security operations shows how it shapes the operational experience end to end.

The In-Process Hub and Per-Connection Channel

Behind the endpoint sits an in-memory hub. The Hub holds a map of every active connection, keyed by a per-connection UUID, and guards that map with a read-write mutex so that many connections can be read concurrently while registration and removal stay safe. There is no external broker, no Redis pub/sub, and no message queue in this path. The hub lives in the process, and publishing to it is an in-process function call.

Each connection in the hub is represented by a small client struct. It carries the user ID, the allCompanies flag, the set of authorized company IDs, and a buffered Go channel of frames. That channel is the per-connection mailbox. When the hub publishes an event, it writes the formatted frame into the channel of every connection that canReceive it, and each connection’s own handler goroutine reads from its channel and writes the frame to the HTTP stream.

The buffer size is deliberate. Each connection’s channel holds 128 frames. That depth absorbs short bursts, a scan finishing while several findings update at once, without forcing the hub to wait on any single slow reader. The buffer is generous enough for normal bursts and bounded enough that one connection can never consume unlimited memory. What happens when even 128 is not enough is a design decision in its own right, covered below.

Keeping the Connection Alive: Ping and Heartbeat

A long-lived HTTP connection that sits silent for a while is fragile. Proxies and load balancers between the browser and the application often close connections they judge idle, and a quiet SSE stream looks idle even when it is perfectly healthy. PMAP keeps the stream alive with two kinds of comment frames that carry no data but prove the connection is still breathing.

The first is an initial ping. The moment a connection is established, the server sends a : ping comment frame. This is not parsed as an event by the client. Its job is to confirm that the HTTP upgrade to a stream succeeded, so the frontend knows immediately that it is connected and listening rather than waiting on a connection that silently failed.

The second is the heartbeat. Every 20 seconds the server writes a : heartbeat comment frame down each open stream. Lines beginning with a colon are SSE comments, so clients ignore them entirely, but the act of writing keeps intermediary proxies and load balancers from treating the connection as dead and tearing it down. The 20-second cadence is short enough to stay under typical idle-connection timeouts and long enough to add no meaningful traffic. The heartbeat ticker runs inside each connection’s own handler goroutine using a standard timer, so there is no global scheduler coordinating keepalives across the platform.

These two frames are the difference between a stream that survives real network paths and one that works only on localhost. They are quiet, they are cheap, and they are what makes the push layer dependable across proxies in production.

From Domain Event to SSE Frame: The Bridge

The realtime domain does not produce events. It only transports them. The events themselves are emitted by the domains where things actually happen, when a finding changes status, when a scan finishes, when a notification is created. Those domains publish to an in-process dispatcher, and a bridge wires selected dispatcher events into the SSE hub.

That bridge is registered at startup in the server’s main entry point. It subscribes to specific internal event types and, for each one, maps the internal event name to the SSE event name the browser expects. This indirection is the point. The domain that emits finding_status_changed knows nothing about SSE, and the frontend that listens for finding.status_changed knows nothing about the internal dispatcher. The bridge sits between them and translates, so transport concerns never leak into domain code. The internals of that in-process dispatcher, the producer-and-consumer side of the picture, are a separate subject we treat on its own.

The mapping is explicit, and it is not one-to-one in name. Several internal events collapse to a single browser event. The table below is the actual bridge.

| Internal event | SSE event sent to browser | |—|—| | finding_created | finding.created | | finding_status_changed | finding.status_changed | | finding_assigned | finding.updated | | finding_retested | finding.updated | | finding_note_added | finding.updated | | sla_breached | finding.updated | | scan_finished | scan.updated | | report_generated | report.updated | | asset_created | asset.created | | asset_updated | asset.updated | | asset_deleted | asset.deleted | | project_updated | project.updated | | notification_created | notification.created | | notification_read | notification.read | | notification_read_all | notification.read_all |

Notice that assignment, retest, note, and SLA-breach events all surface to the browser as finding.updated. The frontend does not need a distinct handler for each internal cause. It just knows a finding it is showing has changed and refreshes that row. Events outside this table, such as approval lifecycle, SLA escalation, bulk updates, and CI or VCS events, are consumed by other subscribers like the notification service and the audit trail, but they are not currently bridged to SSE. The bridge carries exactly the events the live UI needs and no more.

Handling Slow Clients Without Stalling the Hub

A single slow browser tab must never be allowed to slow down everyone else. This is the failure mode that quietly kills naive push systems. One client on a bad network, or one tab the user backgrounded, falls behind on reads, its buffer fills, and if the server blocks waiting for that one client to catch up, every other connection stalls behind it.

PMAP refuses to block. When the hub publishes an event, it writes to each authorized connection’s channel using a non-blocking send. If that connection’s 128-frame buffer still has room, the frame goes in and will be delivered. If the buffer is full, meaning the client is consuming slower than events are arriving, the frame is silently dropped for that connection and a warning is logged. The hub does not wait. It moves on to the next connection. Publication for the whole platform never stalls on one laggy consumer.

Dropping a frame sounds alarming until you remember what the frames are. They are change signals, not the source of truth. A dropped finding.updated frame means one browser tab missed a nudge to refresh one row. The data itself is untouched in the database, and the user will see the current state the next time the page or component loads its data over REST. The system trades a guaranteed delivery promise it cannot keep for liveness it can. For a status feed, that is the correct trade. The logged warning gives operators a signal if a particular client is chronically behind, without ever letting that client degrade the experience for anyone else.

What SSE Does Not Do: No Persistence, REST Refresh on Reconnect

It is as important to be clear about what this layer does not promise as what it does. PMAP’s SSE transport is a live fan-out, not a durable event log, and that boundary is deliberate.

There is no persistence. Events that fire while a client is disconnected are gone for that client. The hub holds connections in memory and writes to them; it does not journal events, it does not replay a backlog, and it does not let a reconnecting client ask “what did I miss.” If a tab was closed or the network dropped for thirty seconds, the events that occurred in that window were never queued for it.

The recovery model is simple and it leans on the rest of the platform. When a client reconnects, it is expected to refresh its data over the normal REST endpoints. The REST API is the source of truth for current state, and the SSE stream is only an accelerator that keeps an already-loaded view fresh in between. So a reconnecting dashboard re-fetches its finding lists, its scan statuses, its notification count, and is immediately current again, regardless of how many SSE frames it missed while away. The push layer makes the live experience fast; REST makes it correct. Each does the job it is good at, and neither pretends to do the other’s.

This division also explains why the realtime domain never touches the database. It is a pure transport. Correctness lives in the domains that own the data, and the SSE layer borrows nothing from them except the events they choose to publish.

How PMAP Pushes Live Updates Everywhere

The payoff of this design is that one SSE stream powers live updates across the whole console. There is no per-page socket and no per-widget poller. The frontend opens one EventSource, listens in a single onmessage handler, and routes each frame by its event.type to whatever component cares.

The notification bell updates in real time on notification.created, notification.read, and notification.read_all, so the unread badge is always accurate without a refresh. Finding lists and detail views refresh a status badge on finding.status_changed, show a new-finding indicator on finding.created, and pick up assignee and note changes through finding.updated. Dashboard and analytics widgets recount on asset.created, asset.updated, asset.deleted, and project.updated. The scan monitor refreshes a scan card’s status on scan.updated, bridged from a scan finishing. The report list raises a report-ready indicator on report.updated. The same scan activity that lights up the monitor also feeds scheduling views, which we cover in the article on scan scheduling and live sync.

Every one of those surfaces is fed by the same stream, scoped per tenant, kept alive by heartbeats, and resilient to slow clients. The result is a console that reflects the current state of a security program the moment it changes, without the latency, the load, or the staleness windows that polling imposes. The realtime layer is intentionally small, and that smallness is what makes it dependable. It transports, it scopes, it keeps alive, and it gets out of the way.

To see how this push transport combines with notification routing and a unified operations timeline into a single live experience, read the pillar on real-time security operations.

Frequently Asked Questions

What are Server-Sent Events and how does PMAP use them?

Server-Sent Events are a browser-native mechanism for a server to push a stream of text events to a page over one long-lived HTTP connection. PMAP exposes a single SSE endpoint at GET /api/v1/events. A browser connects to it with the EventSource API and receives live JSON event frames whenever something changes in the platform, such as a finding status change, a scan finishing, or a new notification. The stream is a pure fan-out that delivers updates and never writes to the database.

How is SSE different from WebSocket for a security platform?

WebSocket provides a full duplex channel where both client and server stream messages continuously, which suits chat or collaborative editing. PMAP’s realtime layer is a one-directional fan-out, where events flow outward to browser tabs and the browser does not need to push high-frequency messages back. SSE fits that shape, rides on ordinary HTTP so it passes through standard proxies, is text-native and easy to debug, and is built into the browser, so it keeps the transport layer thin and matched to the job.

How does PMAP keep one company from seeing another company’s events?

Isolation is enforced on two levels. At connect time the platform resolves the connection’s scope from the user’s token. A platform_admin is marked to receive all events, while every other role gets a bounded set of authorized company IDs read from user_access grants. At delivery time every event carries a company_id, and the hub checks each connection with canReceive before sending a frame. A connection only receives an event when it is platform-wide admin, when the event is a zero-UUID broadcast, or when the event’s company is in that connection’s authorized set.

How does an EventSource connection authenticate if it cannot send headers?

The EventSource API cannot set custom request headers, so it cannot send the usual Authorization: Bearer header. PMAP’s endpoint first checks for that header and, when it is absent, falls back to a ?token=<token> query parameter, which is what lets a plain EventSource connect. Tokens in URLs should be handled with care because query strings appear in logs, so these connections should run over TLS with short-lived tokens. Any client that can set headers should use the header path.

What is the heartbeat for and why is it 20 seconds?

Proxies and load balancers often close connections they judge idle, and a quiet SSE stream looks idle even when it is healthy. To prevent that, PMAP sends a : ping comment frame immediately on connect so the client confirms the upgrade succeeded, then sends a : heartbeat comment frame every 20 seconds. Comment frames carry no data and are ignored by clients, but the act of writing keeps intermediaries from tearing the connection down. The 20-second cadence stays under typical idle timeouts while adding negligible traffic.

What happens to a client that reads events too slowly?

Each connection has a buffered channel of 128 frames. When the hub publishes, it writes with a non-blocking send. If the buffer has room, the frame is delivered. If the buffer is full because the client is reading slower than events arrive, the frame is dropped for that connection and a warning is logged, and the hub moves on without blocking. Because frames are change signals rather than the source of truth, a slow client simply sees current state again the next time it loads data over REST. One laggy client can never stall delivery for everyone else.

What does SSE deliberately not do?

The SSE layer has no persistence, no replay, and no backpressure that pauses producers. Events that fire while a client is disconnected are lost for that client, the hub does not journal or replay a backlog, and a full buffer drops frames rather than blocking. The recovery model is a REST refresh on reconnect. When a client reconnects it re-fetches current state from the normal REST endpoints, which are the source of truth, and the SSE stream resumes keeping that already-loaded view fresh. Push makes the experience fast; REST keeps it correct.

author avatar
PMAP Security Team

Newsletter

Get the next writeup in your inbox

One short email when a new case writeup or detection deep dive ships. No marketing drip, no third-party tracking.