I have become suspicious of optimizations that look spectacular in microbenchmarks.

The pattern is easy to recognize.

You benchmark two implementations. One is two, three, maybe five times faster. The result looks convincing enough to justify changing the production code.

Then you put the faster implementation back into the actual application.

Almost nothing happens.

I wanted to reproduce this effect with something much smaller than a database, so I tested three ways of building a simple in‑memory cache in Go:

  • a regular map protected by sync.RWMutex

  • a map split into 64 independently locked shards

  • sync.Map

The isolated benchmark produced a very clear winner.

sync.Map reached about 29.4 million reads per second.

The simple RWMutex implementation managed about 10.6 million.

That is roughly a 2.8x difference.

Then I put exactly the same cache behind a small HTTP endpoint.

The advantage almost disappeared.

That result turned out to be much more interesting than the microbenchmark itself.

Three deliberately boring caches

I did not try to build a production‑ready cache.

There was no TTL, LRU, eviction, refresh logic, database fallback or distributed invalidation.

I wanted to isolate one question:

How much does the synchronization strategy matter when many goroutines read from the same in‑memory data structure?

The first implementation used a normal Go map and one RWMutex.

type Cache struct {
    mu sync.RWMutex
    m  map[uint64]uint64
}

func (c *Cache) Get(k uint64) (uint64, bool) {
    c.mu.RLock()
    v, ok := c.m[k]
    c.mu.RUnlock()

    return v, ok
}

func (c *Cache) Set(k, v uint64) {
    c.mu.Lock()
    c.m[k] = v
    c.mu.Unlock()
}

This is probably the first implementation I would write for a small service.

It is easy to understand.

It is easy to debug.

And until contention becomes significant, it may be completely sufficient.

The second implementation used 64 shards.

type shard struct {
    mu sync.RWMutex
    m  map[uint64]uint64
}

type ShardedCache struct {
    shards []shard
    mask   uint64
}

func (c *ShardedCache) Get(k uint64) (uint64, bool) {
    s := c.shardFor(k)

    s.mu.RLock()
    v, ok := s.m[k]
    s.mu.RUnlock()

    return v, ok
}

Instead of every key competing for one lock, different keys can land in different shards.

The third implementation used sync.Map.

type SyncCache struct {
    m sync.Map
}

func (c *SyncCache) Get(k uint64) (uint64, bool) {
    v, ok := c.m.Load(k)

    if !ok {
        return 0, false
    }

    return v.(uint64), true
}

func (c *SyncCache) Set(k, v uint64) {
    c.m.Store(k, v)
}

All caches were populated before the benchmark.

The key space contained 65,536 entries.

The workload was intentionally read‑heavy because I first wanted to see how much the synchronization strategy alone could change lookup throughput.

The microbenchmark

For the first test I removed almost everything except cache access.

No HTTP.

No JSON.

No TCP.

No database.

No request parsing.

Multiple goroutines repeatedly selected keys and called Get.

The benchmark was run several times, and I compared the median results from the same environment.

The numbers were approximately:

  • RWMutex map: 10.55 million operations per second

  • 64-shard map: 21.04 million operations per second

  • sync.Map: 29.37 million operations per second

The difference was large enough that it did not need creative interpretation.

The sharded cache was roughly twice as fast as the single‑lock version.

sync.Map was roughly 2.8x faster than RWMutex.

If this were the only benchmark I had run, the conclusion would have been tempting:

Replace the mutex‑protected map with sync.Map and get a huge performance improvement.

But that conclusion contains a hidden assumption.

It assumes that cache lookup is a large part of the work performed by the actual application.

The benchmark never tested that assumption.

What the benchmark actually measured

The isolated test measured something close to:

operation = cache lookup

That is useful.

It tells me something about the cache implementation.

But an HTTP request looks more like this:

request =
    read HTTP request
    + route request
    + parse parameters
    + convert key
    + cache lookup
    + build response
    + encode response
    + write response
    + network stack
    + scheduler overhead

The cache can become dramatically faster while the rest of this path remains unchanged.

That is exactly what happened.

Putting the cache behind HTTP

For the second experiment I created a small net/http server.

Each request performed roughly the following operations:

  1. read a key from the request

  2. convert it to uint64

  3. call the cache

  4. build a tiny response

  5. return the response over HTTP

I intentionally kept PostgreSQL out of this experiment.

Adding a database would have made the result more realistic for many APIs, but it would also have introduced another large variable.

I wanted to see whether HTTP processing alone was enough to hide a large cache‑level improvement.

The answer was yes.

The load test used:

  • persistent HTTP connections

  • 128 concurrent clients

  • 40,000 requests per run

  • the same 65,536-key working set

The tests were performed using Go 1.23.2 in an environment with five available CPUs.

The server and load generator were kept separate so the client benchmark would not execute inside the same Go process as the server.

That distinction matters more than it initially seems.

If the load generator and server live inside one process, both compete for the same scheduler, CPU budget and garbage collector. At that point I may accidentally benchmark my test harness almost as much as the server.

And then the 2.8x advantage disappeared

At the HTTP level, all three implementations ended up in approximately the same range:

15,000 to 16,000 requests per second.

There was run‑to‑run variation.

The ordering between implementations was not stable enough for me to make a serious claim that one cache was consistently faster at the HTTP level.

And that is the important result.

The microbenchmark said:

RWMutex:   ~10.6M ops/s
sync.Map:  ~29.4M ops/s

A huge difference.

The HTTP benchmark said, effectively:

all implementations: ~15K–16K req/s

A small difference.

Changing the cache implementation did not turn a 15K req/s server into a 30K, 40K or 45K req/s server.

The 2.8x component improvement was almost invisible at the application boundary.

This is not a contradiction

The result looks strange only if cache throughput and API throughput are treated as the same thing.

They are not.

Imagine that one request takes 10 microseconds.

Suppose only one microsecond is spent inside the cache.

The remaining nine microseconds are spent somewhere else.

Now make the cache lookup three times faster.

The cache portion falls from approximately 1 microsecond to 0.33 microseconds.

The total request time changes from:

10 us

to roughly:

9.33 us

The cache improved by 3x.

The request improved by only about 7 percent.

And if the cache occupied an even smaller fraction of the request, the difference would be smaller again.

This is basically Amdahl's law appearing in a tiny Go HTTP server.

If fraction P of execution time can be accelerated by factor S, the theoretical total speedup is:

speedup = 1 / ((1 - P) + P / S)

A large S does not help much when P is tiny.

That is the part that microbenchmarks tend to hide.

The microbenchmark was still correct

I do not think the first benchmark was useless.

This distinction matters.

sync.Map really was substantially faster under that isolated read‑heavy workload.

The sharded map really did reduce the cost associated with one global lock.

The benchmark answered a valid question:

Which implementation can perform more concurrent cache lookups under these conditions?

The mistake would be changing the question after seeing the result.

It did not answer:

Which implementation will make my HTTP API 2.8x faster?

That would require an end‑to‑end benchmark.

One of the easiest performance mistakes is getting a correct answer to the wrong question.

Why sharding helped so much in isolation

The RWMutex implementation has one obvious synchronization point.

Every reader needs the same RLock.

RWMutex allows multiple readers to proceed concurrently, so this does not mean all reads execute serially.

But they still interact with the same synchronization structure.

As concurrency increases, that shared point becomes more important.

Sharding changes the geometry of the problem.

Instead of:

all keys
   |
one lock

I get something closer to:

keys 0..N
   |
hash
   |
64 separate locks

Operations touching different shards no longer compete through one shared lock.

That explains why the sharded version roughly doubled lookup throughput in my isolated test.

But it also adds work.

Every lookup needs to determine its shard.

There is more code.

There are more maps.

The implementation is harder to maintain.

In the microbenchmark, reducing contention was worth the extra complexity.

In the HTTP test, the benefit mostly vanished into the rest of the request path.

That changes the engineering decision.

Faster code is not always a faster system

This sounds obvious until a benchmark produces a large number.

29.4 million ops/s looks much better than 10.6 million ops/s.

It feels like a meaningful optimization.

And at the data‑structure level, it is.

But production software rarely exists at the data‑structure level.

A real handler may also perform:

  • authentication

  • authorization

  • tracing

  • logging

  • JSON encoding

  • decompression

  • validation

  • database access

  • RPC calls

  • metrics

  • allocations

  • filesystem operations

If the cache is responsible for 2 percent of request cost, spending two days making it three times faster is unlikely to change the service.

If it is responsible for 60 percent, the same optimization may be extremely valuable.

Without measuring that fraction, I do not know which situation I am in.

The easiest thing to benchmark is often the easiest thing to over‑optimize

There is another reason these mistakes happen.

Small functions are pleasant to benchmark.

A cache lookup can be wrapped in a loop.

A serialization function can be wrapped in a loop.

A hash function can be wrapped in a loop.

Then Go gives me clean numbers.

ns/op
allocs/op
B/op

Very satisfying.

The harder problems are usually less clean.

How much time does the request spend waiting for a database connection?

How often does the scheduler become relevant?

What happens to p99 latency during bursts?

Does GC become expensive only when traffic reaches a certain level?

Is the application limited by CPU, sockets, downstream services or memory bandwidth?

Those questions require more effort.

So it is easy to optimize the part that gives the nicest benchmark output instead of the part limiting the application.

I have started treating that as a warning sign.

If an optimization begins with a microbenchmark, I now want an end‑to‑end measurement before calling it a success.

There is also no universal winner between these three caches

I would not use this experiment to argue that sync.Map is always best.

That is another conclusion the benchmark does not support.

The workload here was intentionally favorable to concurrent reads.

Change the workload and the result can change.

For example:

  • frequent writes change synchronization behavior

  • continuously adding new keys changes the workload

  • deleting keys changes it again

  • a single hot key is different from uniformly distributed keys

  • 64 entries are different from 6 million entries

  • one goroutine is different from hundreds

  • large values change cache behavior

  • pointer‑heavy values may change GC costs

Even the sharding strategy itself matters.

The number of shards matters.

The hash function matters.

The key distribution matters.

The relationship between readers and writers matters.

So I would not ask which Go concurrent map is fastest.

I would ask which implementation is appropriate for this access pattern and whether the map is important enough to optimize at all.

The second question is usually more valuable.

How I would test this in a real service

If I encountered a mutex‑protected cache in production, I would not replace it merely because another implementation wins a synthetic benchmark.

First I would look for evidence that the current cache is actually a problem.

I would check:

  • CPU profiles

  • mutex contention

  • time spent inside cache operations

  • cache hit rate

  • read/write ratio

  • key distribution

  • allocation rate

  • GC CPU

  • request throughput

  • p95 and p99 latency

  • the point where the service starts saturating

If the mutex is clearly visible in the profile, then optimizing it becomes interesting.

If it barely appears, replacing the cache may only improve a benchmark.

The application does not care which code looks faster in isolation.

It cares about the critical path.

A useful hierarchy for performance tests

After this experiment, I find it useful to think about benchmarks in three levels.

Level 1: component

This is the microbenchmark.

It answers questions like:

  • how fast is Get

  • how expensive is hashing

  • how much contention does this lock create

  • how many allocations does this function perform

This is where I observed the 2.8x difference.

Level 2: subsystem

Now the component sits inside part of the real execution path.

For example:

routing
+
validation
+
cache
+
serialization

This tells me whether the component remains important once neighboring work returns.

Level 3: end to end

Now I measure the system at the boundary that users or other services actually see.

For an API this might include:

client
→ network
→ HTTP server
→ application
→ cache/database
→ serialization
→ network
→ client

At this level, many impressive micro‑optimizations become difficult to see.

That does not invalidate them.

It tells me their contribution to the whole system is small.

The number I care about changed

Before this experiment, I could easily look at:

10.55M ops/s

versus:

29.37M ops/s

and think I had found something important.

Now the number I want immediately afterward is:

How much of the real request does this operation represent?

Without that information, the speedup is incomplete.

The cache benchmark told me sync.Map could process almost 2.8 times as many reads as my RWMutex version under the tested conditions.

The HTTP benchmark told me something more useful:

My HTTP server did not care very much.

That distinction is exactly why I ran the second test.

And, increasingly, it is the distinction I want to see whenever someone shows me a large microbenchmark speedup.