When I started building chaos-proxy, the initial goal was simple: make API chaos testing practical for JavaScript and TypeScript teams. I wanted something that could sit between an app and its upstream API and introduce realistic turbulence on demand: latency spikes, intermittent failures, and other behavior that makes integration tests feel closer to production.

Node.js was the obvious first runtime for that because the ecosystem, tooling, and middleware ergonomics are excellent for rapid iteration. It is hard to overstate how productive that setup is when the main audience is already living in npm, TypeScript, and JavaScript test runners.

Later, I rewrote the same proxy in Go to push raw proxy performance further and support higher throughput under load. The intent was not to replace one with the other philosophically, but to explore a different optimization frontier with the same product idea.

This post documents what happened when I implemented the same non-trivial feature in both runtimes: hot config reload. Then I reran the benchmark from my previous article to see how the newer versions compare.

The interesting part is not only the final numbers. It is also how two mature runtimes guide you toward different internal designs, even when you are enforcing the same external behavior contract.

Old benchmark post:
https://blog.gaborkoos.com/posts/2025-10-11-Nodejs-vs-Go-in_Practice-Performance-Comparison-of-chaos-proxy-And-chaos-proxy-go/

Repositories:

Implementing Hot Config Reload in Two Runtimes

The goal of hot config reload was to allow users to update the proxy's behavior without downtime. This means that when a new config is posted to the /reload endpoint, the proxy should parse, validate, and apply the new configuration atomically, without interrupting in-flight requests. This enables advanced testing scenarios where you can change the chaos behavior on the fly to model dynamic production conditions like feature rollouts, traffic shifts, or evolving failure modes.

Both implementations follow the same external contract:

So, the user-facing behavior is aligned. Clients see the same API and guarantees. The internal shape is where Node and Go felt very different.

Runtime Model

Node leaned toward a dynamic runtime object: rebuild middleware/router chain, then swap the active runtime. That style maps naturally to the way Node applications are often composed. Rebuilds are straightforward to express, and the overall control flow stays compact.

Go leaned toward immutable runtime snapshots: config + router + version behind an atomic pointer. In practice, this makes the runtime feel more explicit. You can point to exactly what a request observed and exactly when a new version became active.

Concurrency Model

In Node, most complexity is around making reload writes serialized and safe while requests continue flowing.

In Go, the read/write split is explicit: request path loads one snapshot at request start, reload path builds fresh state under lock, then atomically swaps.

Behaviorally, both approaches are equivalent from a user perspective. The difference is mostly in how obvious the invariants are when you revisit the code weeks later.

In-flight Guarantees

Both versions guarantee request-start snapshot semantics.

In Node, this is easier to accidentally violate if mutable shared state leaks into request handling.

In Go, the pointer-load-at-entry pattern makes this guarantee structurally harder to violate.

That was one of the strongest practical contrasts for me: same requirement, different default safety profile.

Router Lifecycle and Rebuild Mechanics

Node composition is lightweight and ergonomic for rebuilds.

Go rebuilds a fresh router and re-registers middleware/routes on each reload. Behavior is explicit and predictable at the snapshot level, with middleware execution order deterministic only when config uses ordered list elements (not multiple keys in one map). It can look verbose at first, but this explicitness pays off when debugging edge cases around reload timing.

Validation and Rollback Boundaries

Both use the same pipeline: parse -> validate -> build -> swap.

Node gives more dynamic flexibility but needs stricter guard discipline.

Go's type-driven pipeline made failure paths and rollback behavior cleaner to reason about.

In both runtimes, treating build and swap as separate phases was the key to keeping rollback semantics simple.

Stateful Middleware Behavior

Both implementations rebuild middleware instances on reload. That means in-memory middleware state (for example, counters or local token buckets) resets by design after a successful reload. This is intentional and worth calling out to users because it is product behavior, not an implementation accident.

Benchmark Rerun

After adding hot config reload support, I reran the old benchmark setup.

The goal here was not to produce an absolute, universal number for every environment. The goal was to keep the methodology stable enough to compare the old and new versions and see whether the relative shape changed.

System and Test Environment (Same Machine as the Old Article)

This rerun was executed on the same machine as the benchmark in the previous article, with the same local topology (Caddy backend on localhost, proxy on localhost, load generated by hey on the same host).

Machine characteristics:

Benchmark setup characteristics:

Reproducibility command block (same pattern used for this article):

# 1) Start Caddy backend
./caddy.exe run --config Caddyfile

# 2) Baseline (direct Caddy)
for i in 1 2 3; do ./hey -n 1000 -c 50 http://localhost:8080/api/hello | tee -a baseline-caddy-runs.txt; done

# 3) Node proxy benchmark (in another terminal, start proxy first)
npx chaos-proxy --config chaos.yaml
for i in 1 2 3; do ./hey -n 1000 -c 50 http://localhost:5000/api/hello | tee -a node-3.0.1-runs.txt; done

# Stop the Node proxy process before running the Go proxy benchmark (both use port 5000)

# 4) Go proxy benchmark (in another terminal, start proxy first)
./chaos-proxy-go.exe --config chaos.yaml
for i in 1 2 3; do ./hey -n 1000 -c 50 http://localhost:5000/api/hello | tee -a go-0.2.1-runs.txt; done

Versions in this rerun:

I also verified response-size parity for fairness:

This check mattered because an earlier Node run returned compacted JSON (smaller payload), which could bias throughput. The final numbers below use matched response sizes.

Current Rerun (Median of 3)

Scenario

Requests/sec

Avg Latency (s)

P99 Latency (s)

Direct Caddy

24,912.1845

0.0018

0.0156

chaos-proxy Node 3.0.1

3,788.0065

0.0129

0.0318

chaos-proxy-go 0.2.1

7,286.8293

0.0062

0.0248

Old Benchmark Reference (from previous post)

Scenario

Requests/sec

Avg Latency (s)

P99 Latency (s)

Direct Caddy

28,383.8519

0.0016

0.0116

chaos-proxy Node 2.0.0

4,262.3420

0.0115

0.0417

chaos-proxy-go 0.0.5

8,828.0577

0.0053

0.0140

What Changed?

  1. Go vs Node in current versions
  1. Go old vs Go new
  1. Node old vs Node new

Adding hot-reload-safe runtime mechanics introduces measurable overhead even in steady-state forwarding paths, which is why both implementations are slower than their previous versions in this benchmark shape.

I did not trigger reloads during benchmark traffic, so this should be interpreted as structural overhead from the runtime architecture needed to guarantee safe reload semantics, not reload execution cost itself.

Why There Is Overhead Even Without Calling/reload

Even if reload is never triggered during the benchmark request stream, the hot reload feature still changes the steady-state architecture:

In other words, the cost is not from running/reloading repeatedly during the test. The cost comes from maintaining reload-safe invariants all the time.

Conclusion

Implementing the same feature in Node and Go was one of the most useful engineering exercises I have done in a while.

The final behavior contract can be identical across runtimes, but the implementation pressure points are very different:

Performance-wise, the high-level outcome still holds: the Go proxy remains roughly 2x faster than the Node proxy in this benchmark shape. At the same time, both implementations are now better specified in terms of live reconfiguration semantics, which was the actual feature goal. The implementations are likely not fully performance-tuned yet. For now, that trade-off is acceptable for the feature guarantees we wanted.

And yes, it was genuinely fun to build.