hotloop
GitHub
HotLoop FlowApache-2.0

Node-RED's idea, with a runtime that does not fall over.

A flow engine in Go, in one static binary, with an editor of its own and a scheduler that holds up when a sensor starts talking faster than the thing reading it. Your flow files stay Node-RED v1 compatible, so point it at a flows.json and it runs.

Version 0.1.0 is public today under its original name, Emberwire.

It lives at github.com/Embernet-ai/emberwire, and the HotLoop Flow release is coming. It is Apache-2.0 and stays Apache-2.0.

The numbers

Smaller, leaner, and faster, with the limits stated up front.

Both runtimes ran in rootless Podman on one box, with the same five-node flow file deployed to each unchanged, driven by the same load generator in the same sitting. Linux, 12 CPUs, the latest Node-RED image, 8 connections, 30 seconds.

FlowNode-REDRatio
Image size25.1 MB717 MB29×
Memory, idle4.8 MB54.6 MB11×
Memory, under load12.3 MB179.6 MB15×
Throughput3,460 req/s1,290 req/s2.7×
Latency p501.03 ms4.61 ms4.5×
Latency p9916.72 ms23.14 ms1.4×
Cold start2.1–2.3 s4.6–5.5 s~2.3×

Now, walking our own numbers back.

A benchmark table with no caveats under it is marketing wearing a lab coat. These figures are not allowed to say three things.

  1. Cold start includes roughly two seconds of container start that both runtimes pay, so the real gap between the two programs is smaller than that row implies.
  2. The throughput figure includes the whole HTTP stack, on purpose, since that is the only surface both present identically. It is not a measurement of either scheduler in isolation, and it should not be quoted as one.
  3. The p99 gap is far narrower than the p50 gap, for a reason that flatters nobody. Under saturation both runtimes queue, queueing dominates the tail, and the median is the only column where per-request cost actually shows up.

Produced by the benchmark command that ships in the repository, so you can disagree with it on your own hardware. The method, the flow file, the exact commands, and the caveats live in the repo's bench docs.

Why not just keep running Node-RED

Four things, and none of them are fixable with configuration.

There is no back-pressure.

Node-RED puts a setImmediate between every wire hop, and that queue has no ceiling. A fast source outruns a slow sink, the queue grows, and the pod gets OOM-killed with nothing in the log to explain it. node-red#855 has been open since 2016. Every inbox in Flow is bounded, with a policy per node: block, drop the newest, drop the oldest, or raise it to a Catch node and let the flow decide.

It is single-threaded.

One event loop carries the runtime, the editor API, the websocket fan-out, and every node's I/O. One CPU-heavy Function node stalls all of it, including the editor you are using to find out why it stalled. In Flow, every node instance gets its own goroutine, ordered within a node and parallel across nodes.

The Function node's sandbox is not a boundary.

That is not our accusation, it is Node's own documentation: “The node:vm module is not a security mechanism. Do not use it to run untrusted code.” Node-RED's trust model is that anyone who can deploy a flow already owns the box, which is fine on a Pi in a workshop and not fine on a customer's plant floor. It is not hypothetical either: CVE-2025-41656 is unauthenticated remote code execution against a default Node-RED, reached by deploying a flow with an exec node in it.

Credentials are weakly protected.

They are encrypted with AES-256-CTR keyed by a raw SHA-256 of your secret. CTR has no MAC, so anyone who can write the credentials file on a shared volume can flip chosen plaintext bits, which turns “can write a file” into “can change a broker password to one they picked.” And plain SHA-256 is not a key derivation function. There is no CVE on either of those, and both are still real.

What is different, concretely

The same idea, rebuilt around what bites people in production.

Node-REDFlow
RuntimeNode.js, one event loopGo, goroutine per node
Back-pressureNone, unbounded queueBounded inbox, four policies
Message cloningFirst recipient aliases the senderEvery recipient gets a copy
Function sandboxnode:vm, explicitly not a boundarygoja with no host bindings, plus WASM guests with a hard memory ceiling
exec nodeAny command, through a shellDisabled until allowlisted, and no shell at all
File nodesAny path the process can reachScoped to the volume, symlinks resolved
CredentialsAES-256-CTR, SHA-256 as the keyAES-256-GCM, Argon2id
Context APIget, setget, set, CompareAndSwap, Increment, Update
Flow file writesIn placeTemp file, fsync, rename, fsync the directory, three backups deep
Configsettings.js, executable JavaScriptDeclarative YAML
AuthOff by defaultRefuses to start without it
MetricsNonePrometheus, per node
Node definitionA .js plus a hand-written .html twinOne Go descriptor

Flow is deliberately not identical. Node-RED hands the first recipient on a wire the original message and only clones for the rest, so two branches that each believe they own their message can quietly corrupt each other. Every recipient in Flow gets its own copy. That costs 1.4 µs per hop on the author's machine, and a shared path for large binary payloads takes a 1 MB payload from 217 µs down to 341 ns, which is exactly where the copying would have hurt. Every deliberate difference is written down in the compatibility matrix rather than left as a surprise.

Compatibility

51 node types, and each one says how close it is.

A node that is partially compatible and silent about how is worse than one that is obviously absent, because the flow appears to work and quietly does the wrong thing. So a test fails the build if a node does not say what is missing.

10

Full

Behaves as the Node-RED node of the same type does.

26

Partial

A subset. The notes say exactly which parts are missing.

9

Divergent

Deliberately different, and the notes say why.

6

Flow only

No Node-RED counterpart.

Not supported at all: community nodes

They are npm packages that need Node.js. There is no version of this where they work.

Not supported at all: JSONata

Any property typed jsonata is refused with an error rather than ignored, because returning the expression text would make a flow look like it works while routing on a literal string.

Browse all 51 nodes

Where it stands

It runs. It has never run on a plant floor.

Verified against real infrastructure and not merely against its own encoders: MQTT, InfluxDB 2.7, and PostgreSQL 16 in Podman. The race detector is clean on every package. And here is what is not done yet, roughly in the order it bothers the author.

  • Partial deploy. A redeploy currently restarts every node rather than only the ones that changed. Node-RED diffs. This is the last big runtime gap.
  • Link Call, and Link Out’s return mode. Both are refused with an error rather than silently doing nothing, which is the right behavior while they do not exist, and is still a gap.
  • JSONata. Every jsonata-typed property is refused today. Refusing beats returning the expression text and letting a flow route on a literal string, but it is also the most common thing an imported flow trips over.
  • Cron-style Inject scheduling. Interval and startup injection work. “At a specific time, on these days” does not, and crontab is ignored.
  • Editor click-through for the newer nodes. The dialogs are descriptor-driven, so they render, but nothing has been clicked through by hand for the HTTP, WebSocket, TCP, or UDP nodes.
  • Multipart uploads on HTTP In, and cookies on HTTP Response.
  • It has never run on a real plant floor. Everything past the deploy line is unproven in the field, and it will not be described as production-hardened until a plant has tried to break it.
Licensing

Apache-2.0. Free for everyone.

A business can run Flow, modify it, and ship it commercially without asking anyone. That is the point, and it is why Flow is not under the Community License the Gateway uses.

Read the license