I Chased uWebSockets-and Found a 14-Byte Performance Cliff
Building ramjet-ws-js: a Rust-backed WebSocket path for Node.js that wins where it matters, admits where it loses, and survived 584 million verified round-trips. The benchmark result I learned the most from was not a win. At exactly 64 KiB, my WebSocket server was 8.42% slower than uWebSockets.js. It did not crash. It did not corrupt the message. It simply crossed an invisible boundary and became slower. The reason was fourteen bytes. That small discovery ended up explaining the entire project better than any headline performance number. What I was building ramjet-ws-js is a JavaScript WebSocket API backed by a Rust networking core. The normal JavaScript message path looks roughly like this: Socket β Rust decoder β V8/JavaScript callback β Rust β Socket That design is flexible. A handler can run any JavaScript logic it wants. But flexibility has a cost. Every message crosses the Rust-to-JavaScript boundary, creates or transfers a JavaScript buffer, invokes a callback, then sends the response back into Rust. For handlers whose behavior is already known, that journey is unnecessary. So I added an opt-in native execution path: const { App } = require('ramjet-ws') const app = App() app.ws('/*', { nativeEcho: true }) app.listen(9001) With nativeEcho , the data path becomes: Socket β Rust decode and unmask β Rust echo β Socket Open and close events can still reach JavaScript, but message frames stay inside the native reactor. This is not a trick that makes arbitrary JavaScript 46% faster. It is a different execution model: declare simple behavior in advance, then let the native runtime execute it without visiting V8 for every message. In one controlled comparison, the optimized JavaScript callback path reached 400,858 requests per second. The declared native path reached 587,912. That was a 46.7% improvement inside the same application. More than simply removing a callback Avoiding JavaScript was only the beginning. For eligible frames, Ramjet reuses the pooled receive buffer for the response. It unmasks the client payload in place, inserts the server response header, and submits the same buffer for writing. When multiple frames arrive together, the reactor processes them as one fused batch. Unmasking and compaction happen in one pass, and the replies are corked into one write. Tiny messages behave differently. For a single small read, the compact streaming encoder was faster than the fused machinery, so the path switches adaptively. The generic JavaScript path also received several improvements: - Ordered event batching instead of one native callback per event - Coalesced wakeups between JavaScript and the reactor - Per-connection atomics instead of a global lock and lookup - Size-aware buffer ownership - Exact outbound accounting and bounded backpressure - Explicit handling for retained, mutated, and repeatedly sent Node.js buffers The result is not one clever optimization. It is a collection of small decisions that keep unnecessary work out of the hot path. Benchmarking without lying to myself Performance testing becomes marketing very easily. To avoid that, I tested Ramjet against uWebSockets.js, which is backed by uSockets and is one of the strongest WebSocket baselines available. Both servers ran on the same AWS C7i.large: - Two logical CPUs on one physical Xeon Platinum 8488C core - Linux 7.0 - Node.js 22.16.0 - Server pinned to CPU 0 - Client pinned to CPU 1 - Compression disabled - Same 32 MiB maximum-message configuration - One server running at a time - Warm-up before every measured run - Mirrored execution order - Three measured runs, reported as medians The benchmark client did not merely count responses. It verified the opcode, payload size, and exact echoed bytes. A fast incorrect response counted as a failure, not a result. Where Ramjet was strongest The most convincing result came from small pipelined traffic. With 200 connections, pipeline depth eight, and frames written in bursts: - Ramjet: 599,829 requests/second - uWebSockets.js: 518,658 requests/second - Ramjet throughput advantage: 15.65% - Ramjet p50 latency: 14.38% lower - Ramjet p99 latency: 13.28% lower With 50 connections and one 64-byte message in flight, the result was much closer: - Ramjet: 169,468 requests/second - uWebSockets.js: 166,462 requests/second - Ramjet advantage: 1.81% That distinction matters. The design is especially strong when frames arrive in batches and the reactor can fuse work, reduce submissions, and reuse buffers. The payload sweep produced a more interesting picture: | Payload | Ramjet | uWebSockets.js | Difference | |---|---|---|---| | 1 KiB | 131,248 msg/s | 129,985 msg/s | Ramjet +0.97% | | 4 KiB | 91,773 msg/s | 91,183 msg/s | Ramjet +0.65% | | 8 KiB | 72,597 msg/s | 69,358 msg/s | Ramjet +4.67% | | 64 KiB | 16,755 msg/s | 18,294 msg/s | Ramjet β8.42% | | 128 KiB | 9,057 msg/s | 8,782 msg/s | Ramjet +3.14% | | 256 KiB | 4,463 msg/s | 3,765 msg/s | Ramjet +18.55% | | 512 KiB | 1,784 msg/s | 1,729 msg/s | Ramjet +3.23% | | 1 MiB | 811 msg/s | 865 msg/s | Ramjet β6.19% | | 2 MiB | 387 msg/s | 420 msg/s | Ramjet β7.83% | Ramjet is not universally faster. No honest benchmark says that. It is particularly strong for small pipelined traffic and payloads through roughly 512 KiB. uWebSockets.js currently leads at one and two MiB, where Ramjet's full-message reassembly and copying become more expensive. The fourteen-byte problem A 64 KiB payload contains exactly 65,536 bytes. But a masked WebSocket frame carrying that payload also needs a 14-byte header. The actual client frame is therefore 65,550 bytes. Ramjet's pooled receive buffer is 65,536 bytes. The frame misses the whole-buffer fast path by fourteen bytes. The connection still works. Ramjet safely falls back to streaming and reassembly. But it loses the in-place buffer reuse that makes nearby payload sizes fast. I tested a bounded "tail stitch" workaround that joined the missing bytes onto the pooled buffer. It sounded clever. It was slower. So I removed it. That is an underrated part of performance engineering: a clever optimization that loses in a controlled benchmark is not clever enough to keep. Interestingly, the performance did not continue falling after the boundary. Ramjet moved ahead again at 128 KiB and produced its largest payload advantage at 256 KiB. The 64 KiB result was a boundary effect, not a general inability to process large messages. Speed without stability is only a demo After the short benchmarks, I left the same server process under mixed saturation for thirty minutes. Every minute, 95 connections were created across four concurrent workloads: - 50 connections sending 64-byte frames with pipeline depth eight - 30 connections sending 1 KiB messages - 10 connections sending 128 KiB messages - Five connections sending 256 KiB messages The clients ran for 58 seconds, disconnected, and reconnected for the next cycle. The final result: - 584,779,917 exact round-trips - Approximately 833 GiB of application payload across both directions - 2,850 connection sessions - 120 out of 120 workload runs successful - Zero payload mismatches - Zero client failures - Zero server crashes - Zero unexpected timeouts The original server process remained alive for the entire test. Memory told the same story: - Initial RSS: 44.9 MiB - Final RSS: 55.8 MiB - Maximum observed RSS: 56.7 MiB - Process high-water mark: 57.9 MiB The memory high-water mark was established during the initial allocator warm-up and did not continue climbing. File descriptors moved from 24 at rest to 119 under load and returned to 24 after every reconnect cycle. That repeatable 24 β 119 β 24 pattern was more meaningful to me than a single high requests-per-second number. It showed that connections and their resources were actually being retired. Median performance during the mixed soak was: | Workload | Throughput | p50 | p99 | |---|---|---|---| | 64-byte burst | 297,474 msg/s | 1.26 ms | 2.83 ms | | 1 KiB | 36,081 msg/s | 0.78 ms | 2.09 ms | | 128 KiB | 1,011 msg/s | 9.45 ms | 14.67 ms | | 256 KiB | 258 msg/s | 19.10 ms | 26.30 ms | These numbers are lower than the isolated benchmarks because four clients were sharing the same client CPU simultaneously. The point of the soak was stability under contention, not producing another leaderboard result. The limits are intentional-and visible The decoder currently accepts messages up to 32 MiB, but native echo has a four MiB per-connection outbound safety budget. Because a large server-to-client WebSocket frame needs a 10-byte header, the largest native echo payload is: 4,194,304 β 10 = 4,194,294 bytes I tested the boundary directly: - A 4,194,294-byte payload succeeded. - A 4,194,295-byte payload was rejected with WebSocket close code 1008 . - An exact four MiB payload was also rejected. This is not a parser bug. It is backpressure doing what it was designed to do. Simply raising the limit would be easy, but it would allow slow clients to pin much more memory. A better future design is to allow one oversized in-flight message when the normal queue is empty, then stream it through multiple pooled buffers using vectored writes. That would preserve bounded backpressure while supporting larger messages efficiently. Verification beyond benchmarks The final artifact passed: cargo fmt --check cargo clippy --all-targets -- -D warnings cargo test npm run build npm test git diff --check The integration suite covers native text and binary echo, pipelined ordering, 64 KiB and 128 KiB messages, close handling, backpressure, retained buffers, mutated buffers, repeated sends, handle cleanup, and connection leaks. The exact Linux artifact used for the final tests had this SHA-256: 8b2d01e361349d1a7401d91c39955037d31d059219c442a3fd6c7566ff114752 This still does not prove every production workload. The benchmark ran over loopback, which isolates runtime and CPU performance rather than real network conditions. Cross-machine latency, packet loss, TLS, proxies, and u
Comments
No comments yet. Start the discussion.