A quiet change in a recent ComfyUI update broke more API automation than any YouTube “10 ComfyUI mistakes” video ever will. Scripts that had been reliably signaling “the image is ready — download it now” suddenly started waiting 20 to 30 seconds longer than necessary, or worse, raced the output and tried to fetch a file that did not exist yet.
The root cause was not a bug in your code. It was a new WebSocket data type called progress_state, which ComfyUI now emits per node during execution. If your completion-detection logic depended on watching the status message and reading queue_remaining == 0, that assumption has quietly stopped being reliable.
In this guide I’ll walk through exactly what changed, why the naive pattern breaks, and the two robust signals the documentation actually recommends. If you run ComfyUI behind a Python wrapper, a Node service, or any batch pipeline, this is the fix that keeps your queue from stalling. It pairs naturally with the queue-control work I covered in reliably canceling and clearing jobs from scripts, and builds on the broader WebSocket events vs. history polling discussion.
What Actually Changed: The progress_state Message
Historically, ComfyUI’s WebSocket (/ws) returned a small, well-known set of message types. The one most people built against was status, whose exec_info.queue_remaining counter told you how many prompts were still waiting in the queue:
{
"type": "status",
"data": {
"status": {
"exec_info": { "queue_remaining": 0 }
}
}
}
When queue_remaining hit zero, the reasoning went, the prompt was done and the output file was ready. That logic held for most single-prompt rigs for a long time.
Then ComfyUI started emitting progress_state — a new, much richer per-node progress report. Rather than a single queue counter, this message carries an entire nodes dictionary where each entry tracks an individual node’s state (such as finished), its value, and its max. A trimmed example from a real report looks like this:
{
"data": {
"nodes": {
"400": { "node_id": "400", "state": "finished", "value": 1.0, "max": 1.0 },
"503": { "node_id": "503", "state": "finished", "value": 20, "max": 20 },
"504": { "node_id": "504", "state": "finished", "value": 1.0, "max": 1.0 }
}
}
}
The problem is timing. Because this data shows per-node progress, some samplers keep reporting well after the actual generation has finished, which makes the WebSocket look busy even when the console is idle. One user reported on the ComfyUI issue tracker that after a generation completed, their connection kept receiving progress_state for 20 to 30 seconds before a final status with queue_remaining: 0 arrived. Anyone who had wired “fetch the output” to that final status message saw a built-in delay creep into every job.
Why queue_remaining Was Always a Fragile Signal
Even before progress_state arrived, building completion detection on status was a subtle mistake. The status message is defined in the official Server Messages documentation as firing “when the state of the queue changes.” That is a queue event, not an execution event.
Those two are not the same thing. queue_remaining tells you how many prompts are waiting to start. It does not tell you that the current prompt finished generating correctly, that a given node errored out partway, or that the output has actually been written to disk. For a single-prompt-only server, the two happen to coincide closely enough that the bug stayed hidden. Add a second prompt to the queue, a cached node, an interrupt, or a mid-run failure, and the coincidence breaks.
There is a second, harder-to-see failure mode. A contributor on the same issue thread spelled out what most wrapping libraries get wrong: periodically opening and closing WebSocket connections to “check in” on progress is fundamentally unreliable, because you can miss the one message that matters. If your loop connects, reads a few frames, disconnects, and reconnects, a fast completion can slip through the gap and leave you waiting forever for a message you already missed.
The Correct Signal: execution_success and execution_error
ComfyUI’s PromptExecutor has a dedicated mechanism precisely for “this prompt is done,” and it is not status. The built-in message types include execution_start, execution_success, and execution_error. These carry the prompt_id you received back when you POSTed to /prompt, which lets you match a completion to the exact job you submitted.
Here is the dependable flow, which matches the pattern the official API examples recommend:
- POST your workflow JSON to
/promptand capture the returnedprompt_id. - Hold one open WebSocket to
/wsfor the lifetime of the job — do not reconnect in a loop. - Listen for
execution_successcarrying yourprompt_idas the success signal. - Listen for
execution_errorcarrying yourprompt_idas the failure signal. - Only after success, query
/history/{prompt_id}to retrieve the output node’s images.
A minimal Python listener looks like this:
import json, urllib.request, websocket # pip install websocket-client
SERVER = "127.0.0.1:8188"
def queue_prompt(prompt):
body = json.dumps({"prompt": prompt}).encode()
req = urllib.request.Request(f"http://{SERVER}/prompt", data=body)
return json.loads(urllib.request.urlopen(req).read())["prompt_id"]
def wait_for_completion(prompt_id):
ws = websocket.WebSocket()
ws.connect(f"ws://{SERVER}/ws")
while True:
msg = json.loads(ws.recv())
if msg["type"] == "execution_success":
if msg["data"]["prompt_id"] == prompt_id:
return "success"
elif msg["type"] == "execution_error":
if msg["data"]["prompt_id"] == prompt_id:
return "error"
elif msg["type"] == "executing" and msg["data"]["node"] is None:
# Belt-and-suspenders: also treat node=None as completion
if msg["data"]["prompt_id"] == prompt_id:
return "success"
Note the second signal in that loop: the executing message with node set to None. This is ComfyUI’s long-standing “the current execution finished” marker, and it remains valid regardless of the progress_state change. Matching on prompt_id is the key that keeps everything correct when more than one prompt is in flight.
Diagnosing a Stalled or Delayed Pipeline
If you already have a wrapper that suddenly feels slow, the first step is to log raw message types and see what is actually arriving. Add a single line that prints msg["type"] for every frame you receive. Two patterns are the giveaways:
- Long tail of
progress_stateafter the image exists: your code is waiting on the wrong event. Generation finished; you are just staring at per-node progress. Switch completion detection toexecution_successorexecutingwithnode=None. - Reconnect loop that occasionally hangs forever: you are opening and closing connections and missing the completion message in the gap. Switch to a single persistent connection opened before you POST the prompt.
There is also a subtler trap worth checking: cached workflows. When nodes run from cache, the execution_cached message lists the skipped nodes. A cached run still emits execution_success, but it can complete far faster than a full run. If your wrapper assumes a minimum runtime or a minimum number of progress messages, a cached generation will break that assumption. Always key off the completion events, never off “how many progress frames I think I should see.”
Why Not Just Poll /history?
An alternative many people reach for is polling /history/{prompt_id} until the entry appears. This works — it is what the older “submit and forget” examples effectively do — but it has two downsides. First, it introduces polling latency: you are bounded by your own sleep interval, so a prompt that finished in 3 seconds gets noticed up to a full poll period later. Second, it does not cleanly distinguish “still running” from “already errored,” because an error can leave the prompt absent from /history entirely, which looks identical to “still in progress” and can hang your loop forever without a timeout.
Polling /history is fine as a fallback reconciliation step after you have received execution_success — you use it to fetch the actual output filenames. It is a poor primary completion signal.
Putting It Together
The takeaway is simple: status.queue_remaining was never the canonical “done” signal, and the recent progress_state change finally made that fact visible to everyone. The correct contract has been sitting in the official message docs the whole time:
- Submit with
/promptand keep theprompt_id. - Hold one persistent WebSocket.
- Treat
execution_success(orexecution_error) for yourprompt_idas the authoritative completion event. - Accept
executingwithnode=Noneas a legacy-compatible equivalent. - Use
/history/{prompt_id}only to retrieve outputs, not to detect completion.
For the underlying message semantics and the full list of built-in event types, the ComfyUI Server Messages reference is the authoritative source. If you have built any home-grown ComfyUI orchestration — a batch renderer, a front-end that shows progress, or a worker that ships images downstream — auditing your completion detection against these signals is the highest-leverage fix you can make this week. It is the difference between a pipeline that idles for 30 seconds on every job and one that pops the image the instant it is ready.
Leave a Reply