ComfyUIの生成進捗は、HTTPのPOST /promptでjobを投入し、WebSocketの/ws?clientId=でstatus・executing・progress・executedといったイベントを受け取る二経路で取得します。投入時のclient_idと接続時のclientIdを同じ値にすると、自分のjobのイベントだけが届きます。接続からイベントの読み方、UI stateへの変換、切断時の再確認、進捗率の見せ方までを整理します。
情報確認日:2026年8月22日(日本時間)
結論:投入はHTTP、進捗はWebSocket、真実はHTTPで再確認する
責任範囲
- jobの投入と進捗の受信が別経路である理由と、
clientIdでのひも付けを説明する - 現行のServer API(2026年8月時点)が送るイベント名とpayloadを、公式で確認した範囲で載せる
- 内部イベントをUI stateへ変換する層の作り方と、切断後に
/history・/queueで状態を取り戻す手順を示す - Nodeごとの所要時間が均等でない前提で、偽の正確さを避ける進捗表示を考える
- API経路の全体像とHTTPだけの最小実行はComfyUI APIの使い方に任せる
なぜHTTPだけでは足りないのか
POST /promptは、jobをQueueに入れた時点でprompt_idを返して終わり、生成完了を待ちません。HTTPだけで完了を知るならGET /history/{prompt_id}のポーリングになりますが、「今どのNodeか」「samplingが何割か」は分かりません。その途中経過を流しているのがWebSocketです。
- 経路1(投入):
POST /promptにpromptとclient_idを送り、prompt_idを受け取る - 経路2(受信):
ws://127.0.0.1:8188/ws?clientId=同じ値でexecuting・progressなどを受け取る - 経路3(確定):
GET /history/{prompt_id}で出力を確認する
三つ目の経路を残すのが要点です。WebSocketは途中経過の線で、切れても生成は止まりません。「終わったか」「何が出たか」はHTTPの/historyで確かめます。
WebSocketにどう接続するか
server.pyの/wsハンドラは、clientIdクエリがあればそれをセッションID(sid)として使い、なければuuid4().hexを生成します。接続直後に届くstatusメッセージにsidが入っています。
大事なのは、POST /promptのclient_idをこのclientIdと同じ値にすることです。execution.pyではexecuting・executed・execution_errorが投入したクライアントのsid宛てに送られるため、値が違うとjobは動いているのに自分のソケットには何も来ません。
// Node 22 以上(fetch と WebSocket が標準で使える前提)。確認日 2026-08-22
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
const SERVER = "127.0.0.1:8188";
const clientId = randomUUID();
const ws = new WebSocket(`ws://${SERVER}/ws?clientId=${clientId}`);
await new Promise((ok) => ws.addEventListener("open", ok));
const prompt = JSON.parse(await readFile("workflow_api.json", "utf8"));
const res = await fetch(`http://${SERVER}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, client_id: clientId }),
});
const { prompt_id } = await res.json();
console.log("queued", prompt_id);
順番は「先にWebSocketを開き、次に投入」です。逆だと投入直後のexecution_startを取りこぼし得ます。
実行イベントをどう読むか
公式ドキュメントとソースで確認できるイベントを、1回の生成で届く順に並べます。payloadは{"type": "...", "data": {...}}の形です。
| type | data の主な項目 | 意味 |
|---|---|---|
status |
status.exec_info.queue_remaining、初回はsid |
Queueの残数。接続直後と増減時 |
execution_start |
prompt_id |
そのjobの実行開始 |
execution_cached |
prompt_id, nodes |
前回と同じ入力で再計算を省いたNodeの一覧 |
executing |
node, prompt_id |
このNodeの処理を開始。nodeがnullなら全体完了 |
progress |
node, prompt_id, value, max |
Node内の進み(KSamplerなどが報告) |
executed |
node, prompt_id, output |
UI向け出力(画像など)を返したNodeだけが送る |
execution_success |
prompt_id, timestamp |
正常終了 |
execution_error |
prompt_idほかNode情報 |
失敗。どのNodeで何が起きたか |
execution_interrupted |
prompt_id, node_id, node_type, executed |
/interruptなどで中断 |
注意点が三つあります。executedはNodeが終わるたびではなく、UIに返す出力を持つNodeだけが送るので、Nodeの切り替わりはexecutingで追います。progressのvalue・maxが何を数えるかはNode次第で、全Nodeが送るわけではありません。そしてバイナリフレームが混ざります。公式サンプルは「先頭8バイトがメタ、以降が画像」と説明するsampling途中のpreviewで、JSON.parseの前に文字列判定が必要です。
ソース上にはprogress_state(全Nodeのvalue・max・stateをまとめたもの)もありますが、公式の一覧ページに記載がないため、上の表のイベントだけで動く設計にしておきます。
再現実験:1回の生成で受信イベントを時系列保存する
自分の環境で何がどの順で届くかを記録します。件数や間隔は環境依存なので、手順と表だけを示します。
// 上のコードの続き。受信したものを JSONL に追記する
import { appendFile } from "node:fs/promises";
ws.addEventListener("message", async (ev) => {
const t = new Date().toISOString();
if (typeof ev.data !== "string") {
await appendFile("events.jsonl", JSON.stringify({ t, type: "binary" }) + "\n");
return;
}
const msg = JSON.parse(ev.data);
await appendFile("events.jsonl", JSON.stringify({ t, ...msg }) + "\n");
if (msg.type === "executing" && msg.data.node === null && msg.data.prompt_id === prompt_id) {
ws.close();
}
});
| 順 | 時刻 | type | node | value / max | 気づき |
|---|---|---|---|---|---|
| 1 | status |
— | — | queue_remaining は? | |
| 2 | |||||
| … | |||||
| 最後 | executing |
null |
— | 完了までの総時間 |
表を埋めると、progressを送るNodeがどれか、Nodeごとの所要時間の差、バイナリの件数が自分の環境で分かります。この時間の偏りが最後の節につながります。
現在のNodeをUIにどう渡すか
受信イベントをそのまま画面のstateにしてはいけません。イベント名はServer APIの内部仕様で本体更新で変わり得るからです。「イベント→UI state」の変換層を一つ置き、UI側はその層が出す自分の型だけを見ます。
// progress-state.ts — WebSocketの型名を知っているのはこのファイルだけ
export type JobView =
| { phase: "queued"; ahead: number }
| { phase: "running"; nodeId: string; nodeProgress?: { value: number; max: number } }
| { phase: "done" }
| { phase: "error"; detail: string };
export function reduce(prev: JobView, msg: { type: string; data: any }, myId: string): JobView {
const d = msg.data;
if (msg.type === "status" && prev.phase === "queued")
return { phase: "queued", ahead: d.status.exec_info.queue_remaining };
if (d?.prompt_id !== myId) return prev;
if (msg.type === "executing")
return d.node === null ? { phase: "done" } : { phase: "running", nodeId: d.node };
if (msg.type === "progress" && prev.phase === "running")
return { ...prev, nodeProgress: { value: d.value, max: d.max } };
if (msg.type === "execution_error") return { phase: "error", detail: JSON.stringify(d) };
return prev;
}
Node IDをそのまま画面に出しても意味が伝わらないので、API形式JSONのclass_typeをnodeIdから引ける辞書をWorkflow読み込み時に作っておきます。画面の実装は別記事で扱います。
切断・再接続にどう備えるか
WebSocketは、タブ切り替え、スリープ、プロキシのタイムアウトで静かに切れます。切れている間も生成は続き、再接続しても「その間のイベント」は戻りません。WebSocketだけを真実源にすると画面は「実行中」のまま止まります。
- 1
切断を検知する
close・errorイベントに加え、一定時間イベントが来なければ疑う - 2
同じclientIdで再接続する
server.pyは同じsidの古いソケットを捨てて置き換える。以後のイベントは再び届く
- 3
HTTPで状態を取り戻す
GET /history/{prompt_id}にoutputsがあれば完了。なければGET /queueで実行中・待機中のどちらにいるかを見る - 4
UI stateを上書きする
HTTPの結果を正として
JobViewを作り直し、WebSocketは以後の差分にだけ使う
完了の最終判定を/historyにしておけば、WebSocketが一度も届かなくても結果は取れ、落ちても機能は壊れません。Queueの並列数やキャンセルは別記事で扱います。
進捗率をどう見せるか
「全体の何%」を一本の数字で出したくなりますが、Nodeごとの所要時間が均等でないため素直に計算すると嘘になります。checkpointの読み込みは初回だけ長く、KSamplerはstep数に比例し、VAEDecodeは一瞬です。「完了Node数÷総Node数」では、samplingの前に90%に達してそこで止まります。
| 見せ方 | 根拠にする情報 | 向き不向き |
|---|---|---|
| 段階表示 | executingのNode名 |
嘘がない。「読み込み中→生成中→保存中」の3段階で十分なことが多い |
| Node内の進捗バー | progressのvalue / max |
samplerの間だけ正確。送らないNodeでは出さない |
| 全体% | 過去の実行時間の配分 | 同じWorkflowを繰り返す場合のみ。初回や構成変更後はずれる |
段階表示を土台にし、progressが届いている間だけNode内バーを重ねる形を勧めます。全体%を出すなら、実験で記録したNodeごとの所要時間から重みを作り「前回の実行を基にした推定」と明記します。90%で止まるバーより「生成中(12/20)」のほうが状況は正しく伝わります。
よくある質問
clientIdを付けずに接続するとどうなりますか?
サーバーがsidを生成しstatusで返しますが、その値をPOST /promptのclient_idに入れない限り自分のjobのイベントは届きません。先に自分でUUIDを決めて両方に使うのが簡単です。
バイナリフレームは無視してよいですか?
preview画像が不要なら無視して構いません。ただしJSON.parseに渡すと例外になるので文字列判定は必ず入れます。
ブラウザから直接8188のWebSocketにつないでよいですか?
自分のPC内なら問題ありません。他の端末や外部に公開する場合は認証のないServer APIを晒すことになるため、認証とプロキシの設計が先です。その設計は別記事で扱います。
まとめ
ComfyUIの進捗取得は、POST /promptで投入し、同じclientIdで開いたWebSocketからexecuting・progress・executedを受け取り、完了はexecutingのnode: nullと/historyで確定する、という三経路で成り立ちます。
イベント名はUIに直接持ち込まず変換層を挟み、切断後は/historyと/queueで状態を取り戻し、進捗率は段階表示を土台にしてください。HTTPだけの最小実行はComfyUI APIの使い方を参照してください。