Proteus is my systems benchmark comparing Go and Node.js across progressively realistic storage architectures. I implemented a minimal URL shortener in both languages and tested it against five storage designs: a global in-memory map, a sharded in-memory map, PostgreSQL, Redis, and a Redis cache backed by PostgreSQL. The goal was to observe throughput and latency changes while keeping the HTTP API constant and swapping out only the persistence layer.
The baseline version used a single, global in-memory map. The next logical step was to shard that map to reduce lock contention.
The Expectation
Sharding is the standard fix for mutex contention. In a highly concurrent system, a single global lock around a shared resource forces threads to wait in line. By breaking one large map into 32 smaller maps, each with its own lock, concurrent requests can operate on different shards simultaneously.
I expected throughput to improve in both runtimes as the single lock bottleneck was removed.
What Actually Happened
The Go implementation behaved exactly as expected. Throughput jumped significantly when moving from the global mutex (v1) to the 32-shard map (v2).
Here are the real numbers from the 50 VU k6 benchmark:
Go v1 (Global Mutex):
- Throughput: 4,182 RPS
- p95 Latency: 23.21 ms
Go v2 (Sharded Map):
- Throughput: 12,360 RPS
- p95 Latency: 8.78 ms
Node.js went in the opposite direction.
Node v1 (Global Map):
- Throughput: 2,356 RPS
- p95 Latency: 35.26 ms
Node v2 (Sharded Map):
- Throughput: 2,254 RPS
- p95 Latency: 33.78 ms
Node.js handled ~4% fewer requests per second with the sharded architecture than it did with a single global map.
The Implementation
To understand the difference, look at the shard-routing logic. In both languages, saving a URL requires hashing the generated code to find the correct shard.
Here is the Go implementation using FNV-32a:
func (sm *shardedMap) hashCode(code string) uint32 {
h := fnv.New32a()
h.Write([]byte(code))
return h.Sum32()
}
func (sm *shardedMap) getShard(code string) *shard {
index := sm.hashCode(code) % numShards
return &sm.shards[index]
}
And here is the Node.js equivalent implemented for parity:
private hash(key: string) {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) | 0;
}
return hash;
}
private getShard(code: string) {
const index = (this.hash(code) >>> 0) % this.numShards;
return this.shards[index]!;
}
Why Node Got Slower
The difference comes down to what the sharding was actually solving.
In Go, multiple goroutines can execute the store concurrently. The global mutex in v1 therefore creates real contention: requests have to wait for the same lock before accessing the map. Sharding removes much of that contention by giving each shard its own lock.
Node.js doesn't have the same problem. The store operations run on Node's single JavaScript thread, so there is no equivalent mutex contention between JavaScript requests accessing the in-memory map.
That means the sharded implementation adds work without providing the same benefit.
Every lookup and write now has to hash the URL code and calculate which shard to use:
private hash(key: string) {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) | 0;
}
return hash;
}
private getShard(code: string) {
const index = (this.hash(code) >>> 0) % this.numShards;
return this.shards[index]!;
}
The v1 implementation could access the single map directly. V2 performs the additional hashing and shard-selection work on the same JavaScript thread that handles the request.
The benchmark reflects that tradeoff:
- Node v1: 2,356 RPS, p95 35.26 ms
- Node v2: 2,254 RPS, p95 33.78 ms
So throughput fell by about 4%, while p95 latency improved slightly.
Go had lock contention for sharding to eliminate; Node's single-threaded execution model did not. Node therefore paid the additional shard-routing cost without getting the parallelism benefit that made sharding valuable in Go.
Wrapping up
The exact same architectural fix applied to the exact same problem can have opposite results depending on the runtime. A concurrency primitive that removes a bottleneck for a multi-threaded runtime acts as pure overhead in a single-threaded one.
I'll be back with more next week. Until then, stay consistent!
Top comments (1)
Go clearly beats Node even with the global lock, and to a much larger degree with the sharded lock ... by the way, what is Proteus, do you have a link?