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/ https://blog.gaborkoos.com/posts/2025-10-11-Nodejs-vs-Go-in_Practice-Performance-Comparison-of-chaos-proxy-And-chaos-proxy-go/ Repositories: Node implementation: https://github.com/fetch-kit/chaos-proxy Go implementation: https://github.com/fetch-kit/chaos-proxy-go Node implementation: https://github.com/fetch-kit/chaos-proxy https://github.com/fetch-kit/chaos-proxy Go implementation: https://github.com/fetch-kit/chaos-proxy-go https://github.com/fetch-kit/chaos-proxy-go 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: POST /reload accepts a full config snapshot Parse -> validate -> build -> swap, all-or-nothing Deterministic in-flight behavior (request-start snapshot semantics) Reject concurrent reload requests Consistent status model (400, 409, 415, success returns version, and reload duration) POST /reload accepts a full config snapshot Parse -> validate -> build -> swap, all-or-nothing Deterministic in-flight behavior (request-start snapshot semantics) Reject concurrent reload requests Consistent status model (400, 409, 415, success returns version, and reload duration) 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. Node 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. Go Concurrency Model In Node, most complexity is around making reload writes serialized and safe while requests continue flowing. Node 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. Go 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. Node In Go, the pointer-load-at-entry pattern makes this guarantee structurally harder to violate. Go 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. Node 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. Go Validation and Rollback Boundaries Both use the same pipeline: parse -> validate -> build -> swap. Node gives more dynamic flexibility but needs stricter guard discipline. Node Go's type-driven pipeline made failure paths and rollback behavior cleaner to reason about. Go 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: CPU: AMD Ryzen 7 5800H with Radeon Graphics Cores/Threads: 8 cores / 16 threads Base clock: 3.2 GHz RAM: 16 GB DDR4 OS: Windows 10 Home 22H2 64-bit CPU: AMD Ryzen 7 5800H with Radeon Graphics Cores/Threads: 8 cores / 16 threads Base clock: 3.2 GHz RAM: 16 GB DDR4 OS: Windows 10 Home 22H2 64-bit Benchmark setup characteristics: Backend: Caddy serving /api/hello on localhost:8080 Proxy target: localhost:5000 Load generator: hey Command pattern: hey -n 1000 -c 50 http://localhost:/api/hello Runs per scenario: 3 (median reported) Backend: Caddy serving /api/hello on localhost:8080 Proxy target: localhost:5000 Load generator: hey Command pattern: hey -n 1000 -c 50 http://localhost:/api/hello http://localhost Runs per scenario: 3 (median reported) 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 # 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: chaos-proxy (Node): 3.0.1 chaos-proxy-go (Go): 0.2.1 chaos-proxy (Node): 3.0.1 chaos-proxy-go (Go): 0.2.1 I also verified response-size parity for fairness: Caddy: 94 bytes/request Node 3.0.1: 94 bytes/request Go 0.2.1: 94 bytes/request Caddy: 94 bytes/request Node 3.0.1: 94 bytes/request Go 0.2.1: 94 bytes/request 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 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 Scenario Requests/sec Avg Latency (s) P99 Latency (s) Scenario Scenario Requests/sec Requests/sec Avg Latency (s) Avg Latency (s) P99 Latency (s) P99 Latency (s) Direct Caddy 24,912.1845 0.0018 0.0156 Direct Caddy Direct Caddy 24,912.1845 24,912.1845 0.0018 0.0018 0.0156 0.0156 chaos-proxy Node 3.0.1 3,788.0065 0.0129 0.0318 chaos-proxy Node 3.0.1 chaos-proxy Node 3.0.1 3,788.0065 3,788.0065 0.0129 0.0129 0.0318 0.0318 chaos-proxy-go 0.2.1 7,286.8293 0.0062 0.0248 chaos-proxy-go 0.2.1 chaos-proxy-go 0.2.1 7,286.8293 7,286.8293 0.0062 0.0062 0.0248 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 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 Scenario Requests/sec Avg Latency (s) P99 Latency (s) Scenario Scenario Requests/sec Requests/sec Avg Latency (s) Avg Latency (s) P99 Latency (s) P99 Latency (s) Direct Caddy 28,383.8519 0.0016 0.0116 Direct Caddy Direct Caddy 28,383.8519 28,383.8519 0.0016 0.0016 0.0116 0.0116 chaos-proxy Node 2.0.0 4,262.3420 0.0115 0.0417 chaos-proxy Node 2.0.0 chaos-proxy Node 2.0.0 4,262.3420 4,262.3420 0.0115 0.0115 0.0417 0.0417 chaos-proxy-go 0.0.5 8,828.0577 0.0053 0.0140 chaos-proxy-go 0.0.5 chaos-proxy-go 0.0.5 8,828.0577 8,828.0577 0.0053 0.0053 0.0140 0.0140 What Changed? Go vs Node in current versions Go vs Node in current versions Go is still clearly ahead. Throughput: Go is about 1.92x higher than Node (7286.8 vs 3788.0 req/sec). Average latency: Node is about 2.08x slower than Go (0.0129s vs 0.0062s). Go is still clearly ahead. Throughput: Go is about 1.92x higher than Node (7286.8 vs 3788.0 req/sec). Average latency: Node is about 2.08x slower than Go (0.0129s vs 0.0062s). Go old vs Go new Go old vs Go new Throughput decreased from 8828.1 to 7286.8 req/sec (~17.5% lower). Average latency increased from 0.0053s to 0.0062s (~17.0% higher). P99 increased from 0.0140s to 0.0248s. Throughput decreased from 8828.1 to 7286.8 req/sec (~17.5% lower). Average latency increased from 0.0053s to 0.0062s (~17.0% higher). P99 increased from 0.0140s to 0.0248s. Node old vs Node new Node old vs Node new Throughput decreased from 4262.3 to 3788.0 req/sec (~11.1% lower). Average latency increased from 0.0115s to 0.0129s (~12.2% higher). P99 improved from 0.0417s to 0.0318s. Throughput decreased from 4262.3 to 3788.0 req/sec (~11.1% lower). Average latency increased from 0.0115s to 0.0129s (~12.2% higher). P99 improved from 0.0417s to 0.0318s. 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: Requests now run through runtime indirection designed for safe snapshot semantics. Runtime objects and routing/middleware composition are organized around swap-ready boundaries. Concurrency guards and state-boundary discipline are now part of the normal request path design. Requests now run through runtime indirection designed for safe snapshot semantics. Runtime objects and routing/middleware composition are organized around swap-ready boundaries. Concurrency guards and state-boundary discipline are now part of the normal request path design. 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: Node emphasizes dynamic composition and careful mutation control. Go emphasizes snapshot immutability and explicit concurrency boundaries. Node emphasizes dynamic composition and careful mutation control. Go emphasizes snapshot immutability and explicit concurrency boundaries. 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.