Events
A Server-Sent Events (SSE) stream that pushes Composer state changes to long-lived subscribers, so dashboards and integrations can react without polling. One persistent HTTP connection carries every event the subscriber cares about; the browser's native EventSource object handles reconnect with exponential backoff automatically, and the same apikey mechanism the rest of /api/* uses gates the stream.
Why SSE and not polling
A live UI or dashboard needs to know when state changes (a project loaded, an error logged, a thumbnail refreshed) — not just what the state is. Polling for that with a fast loop wastes bandwidth and adds latency proportional to the poll interval; long-polling closes and reopens a connection per event, costing a TCP/TLS handshake per cycle. SSE keeps one connection open, pushes events when they happen, and falls back to the same text/event-stream wire format MJPEG already uses through the Gateway — proven to proxy cleanly with existing infrastructure.
GET /api/events/subscribe
Opens a long-lived text/event-stream connection. Each event arrives as a pair of lines — an event: line naming the event kind and a data: line carrying a single-line JSON payload — separated by a blank line. The server emits a : keepalive comment every 15 s so dead connections are detected within ~30 s on both ends, and intermediate proxies / NAT mappings stay open on otherwise-quiet streams.
Parameters:
| Parameter | Required | Description |
|---|---|---|
topics |
No | Comma-separated list of topics to subscribe to. Default: project. Currently supported: project (project lifecycle), log (live log entries), performance (1 Hz metrics while Running), source (a component's SourceUrl changed). Unknown topics are accepted silently — they just never produce events. |
Response: 200 OK — text/event-stream; charset=utf-8, Cache-Control: no-cache, no-store, must-revalidate, chunked transfer encoding. The response never ends voluntarily; the server keeps writing until the client disconnects or Composer is shutting down.
Topics overview
A topic is a coarse category subscribers filter by; each event within a topic has a finer-grained event name that becomes the SSE event: field. Topics let a subscriber opt into only the streams it cares about — a UI that only needs project-status doesn't have to ignore the log firehose, and the server skips JSON-serialize-and-fan-out entirely for topics with zero subscribers.
| Topic | Description | Typical use |
|---|---|---|
project |
Project lifecycle — Composer Desktop / Runtime is loading, starting, or stopping a project | Top-bar status indicators; auto-refresh of project trees |
log |
Each Composer log entry at level Information / Warning / Error / Fatal | Live log tail UIs, alerting integrations |
performance |
A 1 Hz snapshot of CPU / GPU / RAM / processing-time / queue / error-count / uptime, published only while playback is Running | Live performance panels, frame-budget / health monitoring |
source |
A component's SourceUrl was set (via /api/setproperty), reloaded, or cleared |
Refreshing a preview whose underlying still / media asset changed |
Pass multiple topics by comma-separating them: ?topics=project,log. Each event is delivered to the connection at most once, regardless of how many of its topics were requested.
Events emitted today
| Topic | Event name | When fired | Payload (JSON) |
|---|---|---|---|
project |
project.load |
After a project file has been parsed and is ready (not necessarily running) | { "projectFileName", "topMostSceneId", "activeSceneId" } |
project |
project.start |
After Project.Start() finishes — playback is now Running |
{ "projectFileName", "topMostSceneId", "activeSceneId", "startedAt" } |
project |
project.stop |
After Project.Stop() finishes — playback is now Stopped |
{ "projectFileName", "autoRestart" } |
log |
log.entry |
Each Composer log entry at level Information / Warning / Error / Fatal (Verbose and Debug are filtered at the publisher) | { "level", "message", "loggerName", "eventId", "timestamp" } |
performance |
performance.snapshot |
Once per second while playback is Running (started on project.start, stopped on project.stop) |
{ "playbackState", "isCongestive", "cpuLoad", "systemRamUsageGb", "gpuUtilization", "gpuMemoryUsedGb", "gpuMemoryTotalGb", "gpuEncoderUtilization", "gpuDecoderUtilization", "averageProcessingTimeMs", "maxProcessingTimeMs", "averageProcessingTimePercentage", "averageProcessingTime10kFramesMs", "averageProcessingTime10kFramesPercentage", "processingQueueSize", "numCongestiveFramesSinceStart", "framesProcessed", "skippedFramesDueToHighComputeTime", "numErrorsReported", "numWarningsReported", "numFatalsReported", "applicationUptimeSeconds", "sessionUptimeSeconds" } |
source |
source.modified |
A component's SourceUrl was set via /api/setproperty, or changed via /api/source/reload / /api/source/clear |
{ "targetId", "targetName", "sourceUrl" } (sourceUrl is "" when cleared) |
Event timing notes:
project.loadfires after the project file is fully loaded but before playback starts. WhenDisableWebApiWhenNotRunningis enabled in settings, the rest of/api/*is still closed at this point — wait forproject.startbefore issuing follow-up REST calls.project.startalways fires once playback transitions to Running. UIs that depend on the HTTP API being available should treatproject.startas the "ready" signal rather thanproject.load.project.stopfires for both user-initiated stops and internal recovery cycles (congestive-state restarts). TheautoRestartboolean distinguishes them:truemeans anotherproject.startshould follow shortly.log.entryevents are filtered to Information and above at the source — Debug / Verbose entries never reach the bus, which keeps volume manageable (Composer can emit hundreds of Debug lines per second under load).timestampis UTC ISO-8601.eventIdis the same numeric ID used by Composer's structured logging (seeLogEventIdentifiers).performance.snapshotis published on a fixed 1 Hz timer that runs only while playback is Running: the first snapshot lands ~1 s afterproject.start, the last just beforeproject.stop. While the project is Stopped no snapshots are sent — consumers should freeze the last values and treat the absence as "stale" rather than expecting a zeroed snapshot. Numeric units:cpuLoad/gpu*Utilization/averageProcessingTimePercentageare percentages,*Msare milliseconds,*Gbare gigabytes,framesProcessed/numCongestiveFramesSinceStart/skippedFramesDueToHighComputeTimeare cumulative since the lastproject.start.processingQueueSize> 0 (andisCongestive= true) indicates the render pipeline is falling behind.averageProcessingTimeMs/averageProcessingTimePercentageare the short (50-frame) rolling window;averageProcessingTime10kFramesMs/averageProcessingTime10kFramesPercentageare the long (10 000-frame) window — smoother, better for "is this healthy over time" than the twitchy short window. Both percentages are time / frame-budget × 100.applicationUptimeSecondsis whole seconds since the Composer process started (survives project reloads);sessionUptimeSecondsis whole seconds since the current project started playing (resets to 0 on everyproject.start).source.modifiedfires only for the three HTTP paths that mutate a source:/api/setpropertyon aSourceUrlproperty,/api/source/reload, and/api/source/clear. It does not fire for source changes made directly in Composer Desktop or from a script. MatchtargetId(ortargetName) to decide whether a preview you're showing for that component must re-fetch — a OneShot preview URL is otherwise parameter-stable and would keep showing the old frame.
Wire format
The SSE wire format is intentionally line-oriented and trivial to parse:
event: project.load
data: {"projectFileName":"C:\\Projects\\Show.prj","topMostSceneId":"3f1c…","activeSceneId":"3f1c…"}
event: project.start
data: {"projectFileName":"C:\\Projects\\Show.prj","topMostSceneId":"3f1c…","activeSceneId":"3f1c…","startedAt":"2026-05-14T19:30:12.5Z"}
event: log.entry
data: {"level":"Warning","message":"Audio preview device is using 44100Hz","loggerName":"","eventId":3000,"timestamp":"2026-05-14T19:30:13.1Z"}
event: performance.snapshot
data: {"playbackState":"Running","isCongestive":false,"cpuLoad":42.0,"systemRamUsageGb":3.21,"gpuUtilization":67.0,"gpuMemoryUsedGb":4.12,"gpuMemoryTotalGb":24.0,"gpuEncoderUtilization":12.0,"gpuDecoderUtilization":0.0,"averageProcessingTimeMs":6.4,"maxProcessingTimeMs":14.1,"averageProcessingTimePercentage":38.0,"averageProcessingTime10kFramesMs":6.1,"averageProcessingTime10kFramesPercentage":36.6,"processingQueueSize":0,"numCongestiveFramesSinceStart":0,"framesProcessed":18432,"skippedFramesDueToHighComputeTime":0,"numErrorsReported":0,"numWarningsReported":1,"numFatalsReported":0,"applicationUptimeSeconds":7384,"sessionUptimeSeconds":612}
event: source.modified
data: {"targetId":"3f1c…","targetName":"Logo image","sourceUrl":"file:///C:/Media/logo.png"}
: keepalive
event: project.stop
data: {"projectFileName":"C:\\Projects\\Show.prj","autoRestart":false}
A complete event is event: + data: + blank line. Lines beginning with : are comments — used here for the 15 s keepalive — and must be ignored by clients. The server never emits the optional id: or retry: SSE fields (see "Replay on reconnect" below).
Connection lifecycle
- Open — client GETs
/api/events/subscribe?topics=...with the usualapikeyheader or query parameter; the server replies200 OKand immediately starts emitting events. Any events that fired before the subscription opened are gone — there is no replay window. - Steady state — the server writes events as they fire and a
: keepalivecomment every 15 s. Clients should treat the stream as one-way; no further request body is sent. - Heartbeat detection — if more than ~30 s pass without any traffic (events or keepalives), assume the connection is dead and reconnect. Browsers'
EventSourcedoes this automatically; native clients should set their read timeout to ~30 s and reconnect on timeout. - Close — to unsubscribe cleanly, close the TCP connection (
EventSource.close()in browsers,Disposethe response stream /HttpClientrequest in .NET). The server's loop catches the resulting write failure and tears down the subscription within one keepalive interval.
Authentication
Same apikey mechanism as the rest of /api/*: pass apikey: <key> as a header or ?apikey=<key> in the query string. Browsers don't allow custom headers on EventSource requests, so use the query parameter form from the browser or rely on the Composer Cloud Gateway's cookie auth (which proxies /api/events/subscribe like any other endpoint).
Multiple subscribers and connection limits
Each GET /api/events/subscribe opens one subscription on the server. Multiple clients (or multiple tabs of the same client) can subscribe concurrently; the bus fans out each event to every active subscription that requested its topic. The server has no hard cap on subscriber count, but each subscription keeps one HTTP connection and one bounded in-memory queue alive — practically a few hundred is fine, a few thousand starts to compete with everything else HttpListener is doing.
Browsers apply a per-origin connection limit (~6 in Chrome). A single SSE subscription holds one of those slots for as long as the tab is open, so it's best to consolidate: open one EventSource per page subscribed to all the topics you need, and dispatch internally, rather than one per UI component.
Backpressure and lost events
Each subscription is backed by a bounded queue of 1024 events with a drop-oldest eviction policy. A slow client (background tab, slow uplink) can therefore never back-pressure the publisher — the bus runs at full speed, and stale events are silently dropped from the head of the queue. Clients that absolutely must not miss events should:
- Listen for the SSE
open/ reconnect event, - Refetch any state they care about via the relevant REST endpoint (
/api/project/tree,/api/getstatistics, etc.), - Resume processing.
This works because state is always recoverable from the REST API; the SSE stream is best-effort notification, not the source of truth.
Replay on reconnect
The server does not retain a replay buffer of past events, and does not emit SSE id: fields. When a connection drops and reconnects (the browser's EventSource does this transparently with exponential backoff), any events that fired during the gap are lost. Treat reconnect as "refetch what I care about from REST and resume" — the same recipe as for backpressure above.
Error responses
| Status | Cause |
|---|---|
401 Unauthorized |
API key missing or invalid (when an API key is required) |
503 Service Unavailable |
Composer's HTTP API is currently closed (e.g. DisableWebApiWhenNotRunning is on and playback isn't Running). The subscription request itself is rejected; retry once the project has started. |
A 200 OK response that later closes mid-stream is not an error — it just means the server saw the client disconnect (or Composer is shutting down).
Examples
Browser (JavaScript / EventSource):
const es = new EventSource('/api/events/subscribe?topics=project,log');
es.addEventListener('project.start', e => {
const p = JSON.parse(e.data);
console.log(`project started: ${p.projectFileName}`);
});
es.addEventListener('project.stop', e => {
const p = JSON.parse(e.data);
console.log(`project stopped (autoRestart=${p.autoRestart})`);
});
es.addEventListener('log.entry', e => {
const entry = JSON.parse(e.data);
if (entry.level !== 'Information') console.log(`[${entry.level}] ${entry.message}`);
});
es.onopen = () => console.log('SSE connected');
es.onerror = () => console.log('SSE connection blip — EventSource will auto-reconnect');
// To unsubscribe:
// es.close();
.NET (HttpClient + manual parser):
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("apikey", apiKey);
var url = "http://composer-host:44433/api/events/subscribe?topics=project";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
resp.EnsureSuccessStatusCode();
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string eventName = null, data = null;
while (await reader.ReadLineAsync() is string line)
{
if (line.Length == 0)
{
if (eventName is not null && data is not null)
Console.WriteLine($"{eventName}: {data}");
eventName = data = null;
continue;
}
if (line.StartsWith(':')) continue; // keepalive comment
var colon = line.IndexOf(':');
if (colon < 0) continue;
var field = line[..colon];
var value = (colon + 1 < line.Length && line[colon + 1] == ' ') ? line[(colon + 2)..] : line[(colon + 1)..];
if (field == "event") eventName = value;
else if (field == "data") data = value;
}
The Composer Test SDK ships a typed wrapper around this loop — see ComposerClient.SubscribeEventsAsync in the SDK manual.
curl (smoke test):
curl -N -H "apikey: ${COMPOSER_APIKEY}" \
"http://localhost:44433/api/events/subscribe?topics=project,log"
-N disables curl's output buffering so events appear as they arrive.
When to use SSE vs polling
| Use SSE when… | Use polling when… |
|---|---|
| The UI needs to react immediately when something changes | You just need a periodic snapshot of state |
| Events are bursty (silent for minutes, then a flurry) | State changes at a roughly predictable cadence |
| You want one connection carrying many event types | You need a one-shot value and don't care about updates |
| The state is recoverable from REST on reconnect | You can't tolerate any missed events even with reconnect logic |
For Composer's typical surfaces — project state, log tail, target health — SSE is the better fit; for a one-time "what's the current target status" check, the corresponding REST endpoint is simpler.
Roadmap
Planned topics that aren't shipped yet:
thumbnail—thumbnail.changedevents when a scene's or input'sRenderedCudaBgraImageupdates, so dashboards can refresh static-image previews without polling MJPEG.
When new topics ship, they appear here and the events topic-name list in the parameter table; existing subscribers keep working unchanged.