Skip to main content

Progress streaming (SSE)

A workflow streams live progress over Server-Sent Events. streamEvents(workflowId) opens the stream and yields typed events you iterate as an async iterable. Reach for it when you want sub-second feedback — driving a progress bar, or watching a long-running video operation. When 2-second polling granularity is fine, run() (which polls under the hood) is the simpler path.

Consuming the stream

for await (const event of await client.streamEvents(workflowId)) {
switch (event.event) {
case 'operation.progress':
console.log(`Progress: ${event.data.progress}%`);
break;
case 'workflow.completed':
case 'workflow.failed':
case 'workflow.partially_failed':
// Terminal — break out of the loop.
return;
}
}

The stream returns as soon as the server closes it — which it does on a terminal status. Event tags are dot-delimited; match them as plain strings.

Event tags

TagTerminal?Meaning
operation.progressnoincremental progress (0–100) for one operation
operation.completednoone operation finished
operation.failednoone operation failed
job.completed / job.failednoa job finished
workflow.completedyesthe whole workflow succeeded
workflow.partially_failedyesfinished with some failures
workflow.failedyesthe whole workflow failed

The three workflow.* events are the terminal signals — break out of the loop on any of them.

Malformed frames

A frame whose data: body fails to parse is skipped — the stream stays resilient so a long-running consumer never breaks on one garbled server frame. The skip is silent by default; pass an onParseError callback to observe it. It receives a { raw, event, error } diagnostic identical in shape across both SDKs. This is a low-level hook — the ergonomic run() path always skips silently — and throwing from it aborts the stream.

for await (const event of await client.streamEvents(workflowId, {
onParseError: ({ raw, event: name, error }) => {
console.warn(`dropped malformed "${name}" frame: ${error}`, raw);
},
})) {
// ...
}

If a terminal frame arrives malformed it is skipped like any other, so the stream ends without a recognised terminal event and run() falls back to polling getWorkflowStatus — bounded latency, no hang.

Forcing polling in run() (useSSE)

The ergonomic run() streams progress over SSE first and silently falls back to polling getWorkflowStatus if the stream can't be opened or drops. That default (useSSE: true) suits almost everyone. To skip SSE and poll from the start — e.g. behind a proxy that buffers or blocks SSE — opt out per call. It works the same on the operation-first builders and all five file-first run surfaces (file(...), files(...), and the merge / archive / watermark recipes).

// operation-first
await client.compress('./big.mp4', { quality: 70 }).run({ useSSE: false });

// file-first (same options bag)
await client.file('big-video.mp4').compress(OptimizeFor.Balanced).run({ useSSE: false });

When to use SSE vs polling

Use SSE (streamEvents) whenUse polling (run) when
You need sub-second feedback2-second granularity is fine
You're driving a progress UIYou're running a cron / batch job
You expect long-running workflowsYou expect quick turnaround

A connection can drop for benign reasons (proxy timeouts, laptop sleep).

The SDK does not reconnect

The SDK opens one stream. There is no retry loop and no Last-Event-ID resumption — it ignores the id: and retry: fields entirely. Events published while you were disconnected are lost and cannot be replayed.

Any reconnect loop is yours to write, and after it exits you must cross-check the terminal state with a workflow-status call — the gap is unrecoverable from the stream alone.

Webhooks are a third option — they push terminal events to your server without holding a connection open, and they do not have this failure mode.

See also

  • Workflows — what is being streamed.
  • Errors — failures thrown while opening the stream.