Independent project. Not affiliated with or endorsed by Comfy Org.

All pages

API

Event stream

The Server-Sent Events stream at /api/v2/jobs/{id}/events. Covers its event names and payloads, how it bridges ComfyUI's WebSocket, and why it always ends.

View as Markdown Source on GitHub

The endpoint

GET /api/v2/jobs/{id}/events streams one job's live state as Server-Sent Events. It is a convenience on top of polling. GET /api/v2/jobs/{id} stays authoritative, and a client that never opens the stream sees the same state by polling it.

The stream is live push only. Events carry no id: line, there is no Last-Event-ID resume, and anything sent while a client was disconnected is gone. A client that reconnects gets a fresh snapshot.

curl -sN http://127.0.0.1:8189/api/v2/jobs/813f058f-80fc-4023-82bf-8c20fe3190ca/events
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
Status Code When
200 The stream opens.
404 not_found The id is not a UUID, or neither the proxy nor ComfyUI knows the job.
429 too_many_streams Eight streams are already open. Sent with Retry-After: 5. Poll the job instead, or retry.
503 upstream_unreachable ComfyUI could not be reached for the first snapshot.

The cap is eight concurrent streams per proxy, because each holds a ComfyUI WebSocket open. A slot frees when its stream ends.

A sample run of a short job:

event: status
data: {"status":"running"}

event: progress
data: {"value":0,"nodes_done":0,"nodes_total":0}

event: progress
data: {"value":0.5,"nodes_done":0,"nodes_total":0,"current_node":"3","step":1,"steps":2}

event: preview
data: {"node_id":"","content_type":"image/png","data_base64":"iVBORw0KGgo..."}

event: output
data: {"node_id":"9","name":"out.png","type":"image","content_type":"image/png","size_bytes":1553743,"id":"eyJq...","hash":null,"url":"http://127.0.0.1:8189/api/v2/assets/eyJq.../content","url_expires_at":"2026-10-29T01:03:01.728635+00:00","job_id":"813f058f-80fc-4023-82bf-8c20fe3190ca"}

event: status
data: {"status":"succeeded"}

Events

Event Payload Sent
status {"status", "queue_position"} First on connect, and last when the job ends.
progress A progress snapshot On connect when the job is running, then live, at most about twice a second.
preview {"node_id", "content_type", "data_base64"} Live preview images, at most about twice a second.
output An output object, the same shape as in job.outputs As each node's outputs are committed, and before the terminal status.

No log event is sent, because the v2 contract reserves it. A node execution error shows up as a terminal failed status, and the details are in the job's error field.

status

{"status": "queued", "queue_position": 2}

status is one of the job statuses on the API page. queue_position is present only while the job is queued. The terminal statuses are succeeded, failed, canceled, and expired. The terminal status is always the last event on the stream.

progress

Every progress event is a complete snapshot, so one event re-syncs a client that missed earlier ones.

Field Notes
value Overall fraction, 0 to 1.
nodes_done, nodes_total Finished and total nodes, from ComfyUI's progress_state. Both are 0 when the frame came from ComfyUI's per-step progress message.
current_node The running node id, or null.
step, steps The running node's step and step count, or null.

The snapshot sent on connect comes from the job object and carries only value, nodes_done, and nodes_total.

preview

{"node_id": "12", "content_type": "image/jpeg", "data_base64": "/9j/4AAQ..."}

data_base64 is the encoded image, standard base64. content_type is image/jpeg unless ComfyUI marked the frame as PNG or named another type. node_id is the node that produced the preview when ComfyUI tags it, and an empty string when it does not. The proxy keeps no preview to replay on connect.

output

The payload is the output object from the API page. Each output id is sent at most once per stream, and every output that has not been sent yet goes out before the terminal status. Clients, both SDKs included, stop reading at a terminal status, so an output sent after it would never be seen.

How it bridges ComfyUI

ComfyUI's WebSocket at /ws is its only live signal, and ComfyUI sends a prompt's events only to the client that submitted it. The proxy submits each job with the job id as its client_id, so the stream for a job connects to /ws?clientId=<job id> and receives that job's frames.

On connect the stream:

  1. Takes a snapshot from ComfyUI's /history and /queue. A job that is already terminal gets its outputs and its terminal status, and the stream ends.
  2. Sends the status event, and a progress event when the job is running.
  3. Opens the WebSocket and sends ComfyUI's feature_flags handshake with supports_preview_metadata, so previews arrive tagged with their node id.
  4. Takes a second snapshot now that frames are flowing. A job that finished between the first snapshot and the connect sent its terminal message to nobody, and this check catches it.
  5. Translates frames until the job ends.

Frame translation:

ComfyUI frame Stream
progress, progress_state A progress event, throttled to one per 500 ms.
binary preview A preview event, throttled to one per 500 ms.
executed A fresh snapshot, and an output event for each output not yet sent. Coalesced to one snapshot per 500 ms.
execution_success, execution_error, execution_interrupted A final snapshot, then remaining outputs, then the terminal status.

Frames that name another prompt are ignored. Frames with no prompt_id apply to every client.

Reconcile and heartbeats

Every 15 seconds the stream sends a comment line and takes a fresh snapshot. A terminal snapshot ends the stream the same way a terminal frame would.

: keepalive

The schedule is fixed, and incoming frames never reset it. A real ComfyUI broadcasts frames to every WebSocket, its own status and custom nodes such as crystools' monitor about twice a second. A timer reset by each frame would never fire, and a job whose terminal message was missed would leave the stream open forever. With the fixed schedule, a missed terminal message costs at most one interval.

The snapshot right after connecting and the fixed reconcile together are why a stream always reaches a terminal status while ComfyUI is reachable.

When the WebSocket fails

If ComfyUI's WebSocket cannot be opened, or closes or breaks mid-stream, the stream switches to polling. It takes a snapshot every second, sends a : keepalive comment after each, and ends with the remaining outputs and the terminal status once the job is terminal.

Polling gives up after 10 minutes and closes the stream without a terminal status. A client that sees the stream end without one should poll GET /api/v2/jobs/{id}.

If ComfyUI becomes unreachable after the stream opened, the stream ends. The status line is already sent, so it cannot become an error. Polling reports the problem with a proper 503 upstream_unreachable.

Shutdown

Open streams never go idle, so they would hold a graceful shutdown open. When the proxy stops, every open stream is closed at once, without a terminal event. Treat a closed stream with no terminal status the same way in every case: poll the job.

A minimal client

The loop below reads events with the Go standard library and falls back to polling when the stream ends without a terminal status.

package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

var terminal = map[string]bool{"succeeded": true, "failed": true, "canceled": true, "expired": true}

func main() {
	base, id := os.Args[1], os.Args[2] // http://127.0.0.1:8189 and a job id
	resp, err := http.Get(base + "/api/v2/jobs/" + id + "/events")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		fmt.Fprintln(os.Stderr, "stream refused:", resp.Status, "; poll instead")
		os.Exit(1)
	}

	event, done := "", false
	sc := bufio.NewScanner(resp.Body)
	sc.Buffer(make([]byte, 0, 64*1024), 64<<20) // previews can be large
	for sc.Scan() {
		line := sc.Text()
		switch {
		case strings.HasPrefix(line, "event: "):
			event = strings.TrimPrefix(line, "event: ")
		case strings.HasPrefix(line, "data: "):
			data := strings.TrimPrefix(line, "data: ")
			switch event {
			case "status":
				var s struct{ Status string }
				if err := json.Unmarshal([]byte(data), &s); err == nil {
					fmt.Println("status:", s.Status)
					done = terminal[s.Status]
				}
			case "output":
				var o struct{ Name, URL string }
				if err := json.Unmarshal([]byte(data), &o); err == nil {
					fmt.Println("output:", o.Name, o.URL)
				}
			case "progress":
				fmt.Println("progress:", data)
			}
		}
		if done {
			return
		}
	}
	fmt.Println("stream ended without a terminal status; poll", base+"/api/v2/jobs/"+id)
}

The Go SDK does this for you: Job.Events reconnects when the stream drops and falls back to polling, so it always ends on a terminal status.