What 100 custom ComfyUI nodes do in production, and how they ship
By Anastasiia Butova, ComfyUI and diffusion-model engineer, Belgrade · published 23 September 2026
I have written 100 custom ComfyUI nodes in 16 packs. The number was counted on 15 September 2026 from each pack's registration code, not remembered. Most of them are one of three things: a model wrapped so it runs inside the graph, a decision the graph has to make, or an image operation no existing pack does the way our pipeline needs. Below are real examples of each, then the part that usually gets skipped: how a node gets into a production image, and what happens when the wrong one gets onto a server.
Where the number comes from
A script reads each pack's Python sources and collects node ids in the three registration forms ComfyUI accepts. It found 104 registrations and 100 distinct nodes, since four utility nodes are registered by two packs; if any source cannot be read, it exits with 2 and the count is not published. The biggest packs are a general one with 32 nodes (routing, gates, masks, noise, colour, contact sheets), a SAM 3 gemstone pack with 20 and a tiling and frequency pack with 19. Ten small packs hold one to three nodes each, mostly single-model wrappers. The full table is on /comfyui/.
The repositories are private, and several packs were written inside client and employer work (the largest one's repository description still reads "by Glam AI"), so I describe them instead of linking them. Before this count the site said "100+ nodes, open-source". The number held up; "open-source" did not.
A model, wrapped so it runs inside the graph
NAFNet, SwinIR and HYPIR restore, SAM 3 segments car parts and gemstones, DINOv3 measures cars and classifies, YOLO26 detects. As nodes, they run in the same job as generation, on the same GPU, with no round trip to another service.
Calling the model is the easy part of a wrapper. The rest is making it behave on hardware the graph's author never saw. The SwinIR node has GPU profiles that set tile size and batch, and the workflow JSON carried whatever profile its author picked, say H200 with 141 GB. On RunPod serverless the scheduler hands out whatever the endpoint pool has, from several H100 variants to an RTX PRO 6000 Blackwell. The wrong profile meant out-of-memory errors, retries, and jobs running past the 30-minute timeout. Since version 1.6.0 the node detects the card itself and ignores that setting.
It also used to fall back to the CPU on out-of-memory, where a tile takes about 40 seconds and a full job takes hours. I had that fallback removed in 1.5.8: now the node fails loudly with a memory summary. A slow success nobody notices is worse than an error somebody fixes.
Decisions the graph has to make
A ComfyUI graph has no if. Ours branch like this. A router node puts the real image on the output it chose and a 1×1 black placeholder on every other output. At the start of each branch sits a gate:
def run(self, image):
from comfy_execution.graph_utils import ExecutionBlocker
if not torch.is_tensor(image):
print(f"[HappyinImageGate] NOT a tensor → BLOCK")
return (ExecutionBlocker(None),)
h, w = image.shape[1], image.shape[2]
if h <= 1 or w <= 1:
print(f"[HappyinImageGate] {w}x{h} → BLOCK")
return (ExecutionBlocker(None),)
print(f"[HappyinImageGate] {w}x{h} → PASS")
return (image,)
ExecutionBlocker tells ComfyUI not to run anything downstream of it, so a branch that was not chosen costs no GPU at all. At the end of each branch, a placeholder node with a lazy input either asks for the branch's result or, if its branch was not chosen, passes the 1×1 on without asking; a merge node then hands on the first real image. Without the lazy input, the blocker would reach the merge and block everything after it.
Three of the routers:
- NSFW and degradation. An NSFW flag plus a quality verdict from pixel metrics (JPEG blockiness from the DCT, colour banding, chroma noise, ringing, blur) and a CLIP-based aesthetic score. Four image outputs, from
nsfw_bad_qualitytosafe_good_quality, so a degraded frame can go to an upscale branch and a clean one to a lighter one. - Close-up. Face coverage measured two ways, MediaPipe face-skin segmentation and a face detector's box; the higher signal wins.
- Florence prompt router. In a jewellery retouching workflow, Florence-2 captions the frame, and if the caption names a stone, the gemstone prompt is used.
The Florence router is the one I learned most from. On 30 real client frames Florence named the stones in all 18 frames that had them, and still the stone branch was chosen only 17 times. All three causes were in the matching, none in the model. Plurals: diamonds does not match \bdiamond\b. Negations: "There are 0 gemstones" contains the noun. Shapes: "diamond-shaped design" is a pattern, not a stone. The obvious fix, an optional s, was worse: it fired on all 30 frames, because the negated sentences contain the noun too. The order matters: strip negations, strip shape uses, then match with plurals. And "cut" could not go on the shape list, because "brilliant-cut stone" names a real stone.
On one shared set of 22 captions, the old matching got 13 right, a first proposed fix 17 and the final version 22. The part I would keep in any router: the matching moved out of the node into a plain function, tested on saved captions without Florence and without a GPU. As the commit message puts it, "a regression now costs a second instead of a graph run".
Image operations no pack does our way
The third kind is plain image processing, where existing nodes did something close but not what the pipeline needed:
- Noise. An estimator (Laplacian plus MAD over the smoothest tenth of 64×64 patches, minus a small floor so a clean image does not read as noisy) and a grain matcher that carries a reference image's grain onto the target.
- Alignment. Optical-flow alignment with a deformation fallback, then frequency mixing, so two versions of a frame line up before they are blended.
- Sizes. A latent size snap that stretches a frame to the nearest size in the chosen model's table, every side a multiple of 16, instead of cropping it.
- Tiling. Tile split and merge and frequency decomposition for 6K–10K pixel product photos; the seams that come with tiling are in a separate post.
- Cars. For mashinki, a node that tilts the background plate to match the roll of the cabin camera.
How a node gets into a production image
For OIS.Gold, a production image starts from a vendored bundle: 21 packs, 14 public and 7 private, copied in without their .git folders. The build writes the bundle's commit into the image as .vendor-sha, so an image can say which bundle it was built from. Updating is deliberate: refresh the packs, run the workflows against the new state, commit, rebuild. For a vendored pack, a change on somebody else's main branch cannot reach production overnight.
The bundle was put together on 23 April, after a loss. Four days earlier a GPU host had been terminated, and the snapshot of its container held the outer image but not the custom nodes. One node, the Florence router above, was lost with it. I rebuilt it on 27 April from its inputs and outputs as recorded in the production workflow JSON.
The vendored rule has an exception, and I would rather state it than round it off. Packs under active development, three of mine and a few third-party ones in the Blackwell image, are cloned again on top of the bundle at build time from their main branch, with an optional commit pin for when a branch regresses. For those packs the main branch is production, somebody else's included. That is why my largest pack got CI in August, and the workflow file says it in one line:
Whatever lands on master reaches the next worker rebuild, so master has to stay green.
The tests install no torch on purpose. They cover what runs without Florence and without a GPU, which is exactly where the router bugs were.
Testing is not judging. A new checkpoint or node change is judged on a sheet against the previous version, with the same frozen prompts and the same seed in every slot. The wowfaceaibot page shows two sheets of this kind, from my own character-LoRA runs.
A pack runs when it is imported
Two modules in my largest pack were never nodes. They had no inputs and no registration and existed only for their side effects on import: one wrapped ComfyUI's PromptExecutor.execute to save each new prompt's PNG and workflow JSON, the other patched the save path to prefix the container's name. Useful on a development box. In serverless production, ephemeral workers wrote history nobody would read, and a wrapper with its own try/except around the executor could hide a real workflow error. In May they moved to a separate repository the production build never clones, and any always-on side effect has lived there since.
The same Florence router taught the other half. It loaded Florence-2 with trust_remote_code=True, so on a fresh worker the transformers library fetched the model's Python from the Hugging Face Hub and ran it. In May that code stopped working with the installed transformers. The router caught the error and returned an empty caption, and the cascade ended with workers delivering a flat PNG instead of the layered PSD the retouchers work in. The fix was aimed at our own node: a local copy loaded with local_files_only, and transformers pinned. A global HF_HUB_OFFLINE=1 lasted one day, because it also blocked a community node's legitimate download. In August, on a box with transformers 5, the node switched to the model code inside the ComfyUI-Florence2 pack, which never takes the remote-code path. That second failure had hidden for days behind the same empty caption.
A custom node is Python with the server's permissions
This is the incident behind the security line on /comfyui/. In February 2026 a ComfyUI container on an H100 server I worked on was mining Monero with an XMRig variant; the host itself was clean.
The container's ComfyUI port was open to the internet without authentication. Someone sent it a workflow using SRL Eval from srl-nodes, a public pack. The node does what its docstring says, evaluates any Python you give it, and a comment in its source adds "ComfyUI isn't secure to begin with". On a closed box that is a convenience. On an open port it is a remote shell.
The attacker's code rewrote /opt/ComfyUI/execution.py: the clean file is about 1,282 lines, infected copies ran to 1,700–2,100 and more. On every workflow run the injected code paused the miner so generation had the CPU, then started it again, and downloaded the binary again if it had been deleted. The binaries sat in about 27 directories under names that look like system services, different on every infection: three waves in five days, on 13, 17 and 18 February. Deleting the pack did not help, because ComfyUI-Manager kept a snapshot of installed nodes, saw on restart that srl-nodes was missing, and cloned it back. Reinfection came within minutes.
I wrote the detection-and-removal runbook, with a check script and a cleanup script. The order is the point:
- Block the port from outside first, in the
DOCKER-USERiptables chain, or everything below is undone within minutes. The rules do not survive a Docker daemon restart unless saved. - Kill every process in the container except ComfyUI itself.
- Delete the pack and every Manager snapshot that lists it.
- Delete the binaries, then look for any executable left outside the known tool directories.
- Restore
execution.pyfrom git, the only reliable way, then check its line count and zero hits for the miner's markers. - Restart, confirm the pack has not come back, and commit the clean image.
The check script looks at five things: miner strings in execution.py, extra processes, unexpected executables, connections to the pool, and whether the pack is back. The runbook also lists what must not be deleted: one unfamiliar package in that container was legitimate GPU memory management.
Rereading the check script for this post, I found a flaw. If SSH cannot connect, every count comes back empty, every test falls through to OK, and the script prints RESULT: CLEAN. A check that could not run has to say so as a third result.
In April 2026 Censys published an analysis of a campaign against internet-exposed ComfyUI. SrlEval is on its list of exploited nodes, and when no vulnerable node is installed, the scanner installs one itself through ComfyUI-Manager. The same node, and the Manager again as the installer.
What this means for writing and shipping nodes
- A node's inputs are an API. If a node executes code, reads a path or downloads something based on its inputs, whoever controls the graph controls that.
- ComfyUI stays off the public internet, behind a narrow service; how that works for the car studio on this site is in the companion post.
- Nodes arrive at build time, from a list in git, and a pin beats a branch. Code that arrives at runtime, a snapshot reinstalling a pack or
trust_remote_codefetching Python, is code nobody shipped on purpose. - When production builds from a branch, that branch gets CI, with the GPU-free tests inside the pack.
If you need a node for a step no existing pack covers, or a pipeline made safe to ship, how I work is on /comfyui/.