
ComfyUI API /history Empty? Why and the Fix
ComfyUI's /history/{prompt_id} returns an empty {} until the job finishes, because the server only writes a history entry when a prompt leaves the executor, on success or on error. So an empty response isn't an error and it isn't a result. It means one of four things: the job is still queued or running, it was never accepted, the server restarted and forgot it, or it aged out. Your code has to tell those apart, and the /queue endpoint is how you do it.
Why Does /history/{prompt_id} Return Nothing?
I run ComfyUI on a GPU box at home (I call it La Bestia) and hit it over the API from n8n and Python scripts: blog heroes, lip-sync video, LoRA test grids. The first poller I wrote treated {}as “not done yet” and waited. That's right most of the time, which is exactly why it's dangerous: the other times, it waits forever.
Here's what's going on in the server. When you POST /prompt, ComfyUI validates the graph, gives you a prompt_id and puts the job on an in-memory queue. Nothing goes into history at that point. Only when the executor is done with the job does task_done write history[prompt_id], with the prompt, the outputs and a status. Until that moment, there is no key to return.
| Why it's empty | How you can tell | What to do |
|---|---|---|
| Still pending or running | prompt_id is in /queue | Keep polling with backoff |
| Never accepted | POST /prompt returned 400 with node_errors | Fix the graph; there's nothing to poll |
| Server restarted | Not in history, not in queue | Resubmit (with a new seed if you want a new file) |
| Evicted | Old job on a busy server, past 10,000 entries | Record filenames yourself as soon as the job finishes |
Rule of thumb: an empty history is only “still running” if the queue agrees.
Does ComfyUI Keep History After a Restart?
No, and this is the one that cost me the most time. History is a plain Python dict on the prompt queue. Restart ComfyUI (a crash, an out-of-memory kill, a custom node update, Windows deciding to reboot) and it starts empty. Files already in output/ survive on disk, but the mapping from your prompt_id to those filenames is gone.
I found this on a lip-sync video job. The model loaded, VRAM spiked, ComfyUI went down and came back on its own, and my poller kept getting {}for a job that no longer existed anywhere. It sat there until the timeout, and the n8n run logged “queued OK” because that's the last thing it had actually confirmed. Since then my rule is to verify the output file, never the fact that a prompt was queued.
The same applies to eviction. ComfyUI caps history at 10,000 entries and drops the oldest first. You won't hit that on a hobby box, but a shared server running batch jobs all week can. If anything downstream needs to find a file later, store the filename in your own database the moment the job completes.
How Do You Tell “Still Running” From “Gone”?
GET /queue returns two arrays, queue_running and queue_pending. Each item is a list whose second element is the prompt_id. So the loop is: ask history, and if it's empty, ask the queue. If neither has heard of your job, stop waiting. This is the poller I use now, trimmed down:
import time, requests
BASE = "http://127.0.0.1:8188"
def submit(graph: dict) -> str:
r = requests.post(f"{BASE}/prompt", json={"prompt": graph})
body = r.json()
if r.status_code != 200:
# Rejected at validation: no prompt_id, nothing will ever reach history
raise RuntimeError(f"{body.get('error')} {body.get('node_errors')}")
return body["prompt_id"]
def in_queue(pid: str) -> bool:
q = requests.get(f"{BASE}/queue").json()
return any(item[1] == pid
for item in q["queue_running"] + q["queue_pending"])
def wait(pid: str, timeout: float = 900) -> dict:
delay, deadline = 0.5, time.time() + timeout
while time.time() < deadline:
h = requests.get(f"{BASE}/history/{pid}").json()
if pid in h:
return h[pid]
if not in_queue(pid):
# Re-check history once: the job may have finished between calls
h = requests.get(f"{BASE}/history/{pid}").json()
if pid in h:
return h[pid]
raise RuntimeError("job lost (restart or eviction), resubmit")
time.sleep(delay)
delay = min(delay * 1.5, 5)
raise TimeoutError(pid)Two details matter. First, the second history check: a job can finish in the gap between your history call and your queue call, and without it you'd report a successful job as lost. Second, the backoff. Hammering /historyten times a second while a model is loading doesn't make it faster; starting at half a second and capping at five is plenty for image jobs. Video jobs get a longer timeout, not a faster loop.
Why Is outputs Empty When History Says the Job Finished?
Once the entry exists, don't jump straight to outputs. Read status first. It has three fields: status_str ("success" or "error"), completed, and messages. A failed job still gets a history entry, and its execution_error message tells you the node, the exception type and the message. A CUDA out-of-memory shows up right there. Checking only whether outputs is non-empty hides every one of those.
If the status is success and outputs is still empty or missing your image, it's one of these:
- No output node on that branch. Outputs only list nodes that report UI output:
SaveImage,PreviewImage, a video combine node. A graph that ends in a VAE decode and nothing else runs fine and reports nothing. - You grabbed a preview.
PreviewImageresults come back withtype: "temp". They're real, but they live in the temp folder and get cleaned up. UseSaveImagefor anything you keep. - The whole graph was cached. Queue the exact same graph twice and ComfyUI serves every node from cache. History reports the file from the earlier run and no new image is written. If you expected a fresh render, change the seed.
- You exported the wrong JSON.The API wants the “Export (API)” format, keyed by node ID with
class_typeandinputs. The regular UI workflow file is a different shape and fails validation, which puts you back in the “never accepted” row.
I covered more of the workflow-hygiene side in my ComfyUI best practices post. The short version here: status_str first, outputs second, the file last.
How Do You Actually Get the Image File?
Each output entry gives you filename, subfolder and type. Pass all three to GET /view and you get the bytes back. Then check that you actually got bytes:
def fetch_outputs(entry: dict) -> list[bytes]:
st = entry.get("status") or {}
if st.get("status_str") != "success":
errs = [m for m in st.get("messages", []) if m[0] == "execution_error"]
raise RuntimeError(errs or st)
files = []
for node_out in entry["outputs"].values():
for key in ("images", "gifs", "videos"):
for f in node_out.get(key, []):
r = requests.get(f"{BASE}/view", params={
"filename": f["filename"],
"subfolder": f.get("subfolder", ""),
"type": f.get("type", "output")})
r.raise_for_status()
if len(r.content) < 1024:
raise RuntimeError(f"suspiciously small file {f['filename']}")
files.append(r.content)
if not files:
raise RuntimeError("success but no files: add a SaveImage node")
return filesThe size check looks paranoid. It isn't. When ComfyUI sits behind a Cloudflare tunnel, a request without a browser-like User-Agentcan get a 403 HTML page instead of your PNG, and a script that only checks for “response came back” will happily save the error page as hero.png. I tunnel services the same way I described for n8n webhooks behind Cloudflare Access, and the fix is the same: send real headers, and validate what you received.
Should You Use the WebSocket Instead of Polling?
The websocket at /ws?clientId=<uuid> is the more precise signal. Send the same client_id in your POST /prompt body and you get executing events per node, execution_cached, execution_error and execution_success for your prompt. An executing event with node: null for your prompt_id also means it's done. ComfyUI ships a websocket example script in the repo that's worth reading.
My take: use the websocket for anything a person is watching, and polling for batch jobs. For a UI with a progress bar, the socket is clearly better. For a batch job that fires from n8n and might outlive a network blip or a ComfyUI restart, the socket adds reconnect logic and still doesn't tell you anything the queue check doesn't. A dropped socket looks exactly like a slow job. Either way, the last step is the same: fetch the file and check it.
One more thing for n8n users. If a retry fires while the first attempt is still rendering, you get two renders and two files. The idempotency patterns from my post on n8n duplicate executions apply directly: key the job on your own request ID, store the prompt_id against it, and check the queue before you resubmit.
The Checklist I Run Before Trusting a ComfyUI Job
- Read the
POST /promptstatus code. 400 means rejected. Lognode_errorsand stop. - Poll history with backoff. Empty is normal for a while.
- When history is empty, ask
/queue. In the queue means wait. In neither means the job is lost, so resubmit. - Read
status_str. On error, surface theexecution_errormessage, not a generic “failed”. - Fetch every file via
/viewand check its size.Then store the filename yourself, because history won't keep it for you.
The endpoints themselves are documented in the official ComfyUI server routes reference. What the docs don't spell out is that {}is four different answers. Once your code treats it that way, the “my job vanished” bugs mostly go away.
Need ComfyUI Wired Into a Real Pipeline?
I build self-hosted image and video pipelines that run ComfyUI from n8n or your app, with retries, file checks and storage that don't fall over when the GPU box restarts. Tell me what you're generating and where it needs to end up.
History behavior (entry written in task_done, in-memory storage, 10,000-entry cap, status_str/completed/messages) and the /prompt and /queue response shapes verified 27 September 2026 against ComfyUI's execution.py and server.py on the master branch.
Related Posts
AI Models
ComfyUI Best Practices: My Production Image Pipeline on an RTX 5090
Hard-won ComfyUI best practices from a production SDXL pipeline — native resolution vs upscaling, FaceDetailer for tack-sharp eyes, LoRA OOM fixes, and reusable workflow architecture.
AI Models
Claude Opus 5.5 vs Opus 5: What Actually Changes
Claude Opus 5.5 is cheaper than Opus 5 on every line — $4/$20 per MTok and cache reads at $0.20 instead of $0.50 — with the same 1M context and 128K output. But four request shapes now return 400, and the default effort level drops from high to medium with no error attached. The full migration, the real cost math, and why your agent UI went quiet between tool calls.
AI Models
Fable 5.1 Pricing: Cheaper Only If You Cache
Fable 5.1 kept the $10/$50 per-token price and cut cache reads 75% to $0.25/M. Your bill drops by 0.75 times your cache-read share — and not a point more. The math, plus the three changes that break agent loops.