From Frameworks to Metal: How to Reach 102,000 RPS with Go and Stop Being Afraid of High Load

From Frameworks to Metal: How to Reach 102,000 RPS with Go and Stop Being Afraid of High Load

There comes a point in a developer's career when you start getting tired of constraints.

Standard frameworks. Ready-made ORMs. Abstraction on top of abstraction. Layers that exist to hide other layers. And somewhere at the bottom of all of it, a simple map that just wanted to find a key.

At first, it's convenient. Then it gets cramped.

Eventually, you start wanting to do the opposite: remove everything unnecessary and see what the machine can actually do when you stop getting in its way.

That's how my current project evolved — a high-load product aggregator written in Go.

No SQL in the hot path. No heavyweight frameworks. No standard JSON marshaler. Just mmap, custom indexes, direct memory access, and a little bit of unsafe.

And the result turned out to be somewhat more interesting than I expected.


From Syntax to Hardware

Learning Go usually starts innocently enough.

First:

go func() {
    ...
}()

Then come:

  • goroutines;
  • channels;
  • sync.Mutex;
  • WaitGroup;
  • sync.Map;
  • sync.Pool;
  • atomic;
  • worker pools;
  • queues;
  • caching;
  • Docker;
  • microservices.

And then real-world load arrives.

Suddenly, the question is no longer:

Do you know how to write concurrent code?

The question becomes:

How much work actually needs to be done for a single request?

That's a completely different level of optimization.

Because if a request goes through ten abstractions, creates several objects, performs multiple data conversions, hits a database, retrieves the data, converts it into structs, and then converts those structs back into JSON — no amount of atomic is going to save you.

And that's when the evolution starts going in reverse.

Slices instead of unnecessary maps.

Direct memory access instead of intermediate objects.

Atomic operations where an atomic is actually enough.

mmap instead of constant I/O.

Indexes instead of calculations.

Raw bytes instead of conversions.

And eventually, one question:

What if we simply remove everything from the hot path that isn't absolutely necessary?


Makodb: A Database That Doesn't Try to Be SQL

The project is built around a custom mmap-based KV database — Makodb.

Its job is deliberately narrow: serve data as quickly as possible for read-heavy workloads.

There is no SQL query, ORM, or conversion pipeline in the hot path:

storage
   ↓
object
   ↓
ORM
   ↓
struct
   ↓
JSON

Instead:

mmap
  ↓
index
  ↓
docID
  ↓
raw data
  ↓
JSON buffer

The main principles are:

  • lock-free reads;
  • one write lock per shard;
  • mmap;
  • sharding;
  • sorted uint64 indexes;
  • merge-style intersection/union;
  • direct range queries;
  • sort indexes;
  • minimal allocations;
  • no objects in the hot read path.

One particularly interesting component is the turbo index.

An index is simply a sorted array of uint64 values:

[docID1][docID2][docID3]...[docIDN]

So intersecting two indexes becomes a standard merge operation:

A:  1  4  7  12  20  35
B:  2  4  8  12  19  35

AND:

    4        12       35

No temporary maps.

No temporary objects.

No O(n*m).

Just two pointers and sequential memory access.


700,000+ Landing Pages and 4 Million+ Products

This isn't a synthetic benchmark with a single Hello World endpoint.

The actual project contains:

  • 700k+ landing pages;
  • 4M+ products;
  • categories;
  • slugs;
  • sorting;
  • filters;
  • price ranges;
  • text indexes;
  • pagination;
  • different combinations of parameters.

And one principle is particularly important:

There is no special magic for page 1 and page 500,000.

There is no situation where the first page is instant and deep pagination suddenly turns into OFFSET 500000.

There is an index.

The required position can be accessed directly.

Sorting is also not performed from scratch for every request — prebuilt sort indexes handle it.

Filtering becomes a set of operations on indexes.


And Then It Turned Out the Database Wasn't the Problem

This was probably the most interesting stage.

Under normal load, data queries were completing in roughly:

0.161–3 ms.

But the browser was showing considerably more.

So I started measuring.

Data retrieval — fast.

Filtering — fast.

Indexes — fast.

Result composition — fast.

And then:

w.Write(...)

Suddenly, it became clear that sending the result could sometimes cost more than computing it.

That's where things got interesting.


Goodbye, encoding/json

The standard:

json.NewEncoder(w).Encode(v)

is perfectly fine for most applications.

But if the goal is to squeeze every possible bit of performance out of a single machine, the standard JSON marshaler becomes expensive.

So I built SilentJSON.

The idea is simple:

We already know the response structure. Why should the runtime figure it out again?

Instead of dynamically inspecting types through reflection, the serializer uses precomputed field metadata, offsets, and specialized marshal functions.

Conceptually:

struct
  ↓
known offset
  ↓
direct memory access
  ↓
append bytes
  ↓
JSON

Instead of:

interface{}
   ↓
reflect
   ↓
temporary object
   ↓
conversion
   ↓
JSON

And this leads to another important principle.


If the Structure Is Known, Don't Make It Universal

Universality is usually considered a virtue.

But universality has a cost.

For example:

map[string]interface{}

is convenient.

But you pay for that convenience in:

  • predictability;
  • allocations;
  • cache locality;
  • type safety;
  • serialization speed.

For a narrow, specialized project, you can make a completely different tradeoff.

For example:

[]{
    key,
    weight,
    value
}

Or a specialized raw type.

Or a response structure whose layout is known in advance.

It's less universal.

But extremely transparent.

And fast.


The Most Revealing Experiment: GET vs HEAD

At some point, there was a very simple way to test the hypothesis.

If HEAD executes the entire internal request logic but doesn't send the response body, we can compare it directly with a normal GET.

The results were very revealing.

At:

Concurrency: 5800
Duration:    60s

HEAD

Total: 6,167,463 requests
RPS:   102,339
Errors: 0.0%

Average latency:

/shop                 44 ms
/shop/{category}      45 ms
/shop/{slug}          92 ms
/products              44 ms
/products/turbo        45 ms
/categories/tree      46 ms

Maximum latency:

310–483 ms

In other words, the entire internal workload can be processed at more than 100,000 requests per second.

Now the same server, the same workload, the same concurrency — but a real GET.

GET

Total: 3,355,057 requests
RPS:   55,632
Errors: 0.0%

Average latency:

/shop                 84 ms
/shop/{category}      86 ms
/shop/{slug}         164 ms
/products              81 ms
/products/turbo        77 ms
/categories/tree      81 ms

There it is.

Almost a factor of two.

Not search. Not indexes. Not filtering. Not the database.

The result has to be assembled and physically pushed out of the machine.


Where the Algorithm Ends and Physics Begins

The architecture now looks roughly like this:

                    102k RPS
                       │
                       ▼
             ┌─────────────────┐
             │    Go / CPU     │
             │                 │
             │     Makodb      │
             │     indexes     │
             │     filters     │
             │     business    │
             └────────┬────────┘
                      │
                  JSON ready
                      │
                      ▼
             ┌─────────────────┐
             │ output pipeline │
             │                 │
             │ memory → kernel │
             │ → socket → NIC  │
             └────────┬────────┘
                      │
                      ▼
                   ~55k RPS

And this is where it gets slightly funny.

Because the application is already saying:

"I can do more."

And the hardware responds:

"Where exactly are you planning to put all those bytes?"


5,800 Concurrent Requests

At 5800 concurrent requests, the system reached approximately:

55–56k RPS of full GET requests.

And the interesting part is that it doesn't collapse.

0 errors
0.0%

No avalanche of timeouts.

No gradual degradation into death.

No sudden throughput collapse.

It simply reaches its physical ceiling.


What Happens at 105,800 Concurrent?

Naturally, the next question was:

What if we push it harder?

So:

go run main.go -c 105800 -d 60s

Result:

Total: 3,419,927 requests
RPS:   54,801
Errors: 0.0%

Average latency increased:

/shop             ~1.51 s
/category         ~1.52 s
/slug             ~3.03 s
/products         ~1.51 s
/products/turbo   ~1.51 s
/categories       ~1.51 s

But the server didn't die.

That's an important distinction.

The system couldn't process more than roughly 55k RPS — but it held that ceiling under an absurd number of concurrent requests.

So as concurrency increases:

200
  ↓
800
  ↓
2800
  ↓
5800
  ↓
15800
  ↓
55800
  ↓
105800

throughput eventually stops growing.

But it doesn't collapse.

We hit a plateau.


Load Test Series

ConcurrencyRPSAvg latencyErrors
20036,2092–9 ms0%
80053,2027–23 ms0%
2,80055,78134–78 ms0%
5,80055,63277–164 ms0%
15,80055,868219–448 ms0%
55,80055,061794–1,599 ms0%
105,80054,8011,507–3,025 ms0%

And that's probably the most interesting graph in the whole experiment.

RPS
60k ┤
    │        ┌─────────────────────────────────────
55k ┤────────┘
    │
50k ┤
    │
40k ┤
    │
30k ┤
    │
20k ┤
    │
10k ┤
    └──────────────────────────────────────────────
      200  800  2.8k  5.8k  15.8k  55.8k  105.8k

After roughly 2–3 thousand concurrent requests:

throughput becomes almost horizontal.

What keeps increasing is latency.

That's classic behavior for a system that has reached a resource ceiling.


The Funny Part: This Isn't a Server Cluster

This experiment wasn't performed on some enormous fleet of machines.

There wasn't:

load balancer
    ↓
20 application servers
    ↓
10 database servers
    ↓
Redis cluster
    ↓
Kafka cluster

It's one machine.

Ordinary workstation/home-server hardware.

And that's exactly why the experiment became interesting.

At one point I used to test what Go could do with a simple Hello World.

And I remember a very different picture: after a few hundred requests, things started going south.

Here:

5,800 concurrent
→ ~55k GET RPS

5,800 concurrent
→ ~102k HEAD RPS

105,800 concurrent
→ ~55k GET RPS
→ 0 errors

At some point, you stop asking:

"How do I optimize Go?"

And start asking:

"Where exactly does the program end and the computer begin?"


Why Does This Work?

1. Minimal Work Per Request

The main secret isn't some magical function.

It's much more boring:

A request should do as little work as possible.

If the data is already indexed, don't search for it.

If the sort order is already known, don't sort it.

If two conditions can be intersected using a merge operation, don't build another map.

If the JSON structure is known, don't discover it through reflection.

If the document already lives in mmap, don't drag it through several intermediate representations.


2. Indexes Instead of Computation

A product may have indexes for:

category
brand
price
rating
date
text
...

A query such as:

category = electronics
AND
brand = x
AND
price = 5000..50000
ORDER BY price DESC

doesn't become:

find all products
→ check category
→ check brand
→ check price
→ sort everything
→ take 60

Instead:

category index
       ↓
      AND
       ↓
brand index
       ↓
      AND
       ↓
price range
       ↓
sort index
       ↓
page

Most of the work is performed on compact numeric indexes.


3. mmap

Data lives in memory-mapped storage.

The read path doesn't have to constantly perform traditional filesystem I/O.

Conceptually:

disk file
   │
   ▼
 mmap
   │
   ▼
memory address
   │
   ▼
read

This is particularly well suited to read-heavy workloads.


4. Lock-Free Read Path

Writes are a different story.

But reads shouldn't have to wait for other reads.

There is no global:

mu.Lock()
...
mu.Unlock()

on every request.

The hot read path uses atomic reads and direct memory access.

Sharding additionally distributes write contention.


5. JSON Became the Last Enemy

And this is probably the most important conclusion of the entire experiment.

After optimizing the database, indexes, and search, it turned out that the next major problem wasn't retrieving the data.

It was sending it.

That's why moving to a specialized JSON marshaler made such a noticeable difference.

And then something even more interesting became apparent:

Even when JSON is already as lean as possible, the bytes still have to physically leave the machine.

At that point, no architectural abstraction can help.

The bytes have to move.


Where Is the Real Bottleneck Now?

Very roughly:

request
   │
   ├── routing
   ├── filtering
   ├── index intersection
   ├── sorting
   ├── document lookup
   ├── JSON composition
   │
   └── network output

The first stages have become fast enough that optimizing them further no longer produces the biggest gains.

Now the problem is much more physical:

throughput ≈ available bandwidth / response size

Of course, the real system is more complicated.

But at this scale, that's already the right way to think about it.

If a response is N bytes and the server needs to send tens of thousands of those responses every second, we very quickly stop talking about algorithms.

We start talking about:

how many bytes per second the entire system can move.


And That's Where It Gets Really Funny

You optimize the algorithm.

Then memory.

Then indexes.

Then JSON.

Then unnecessary conversions.

And eventually you discover:

CPU:
    "I can still go."

Makodb:
    "I can still go."

Indexes:
    "We're barely working."

JSON:
    "I've already been put on a diet."

Network stack:
    "Guys..."

PCIe:
    "Slow down."

NIC:
    "Where exactly are you taking all those bytes?"

And this is actually a beautiful stage of optimization.

Because it means you have finally reached the hardware.


What I Took Away from the Experiment

The most important result wasn't 102,339.

It wasn't even 55,632 RPS.

The real conclusion is much simpler:

High load itself isn't scary. The scary part is how much work you force the system to perform for each request.

If a request requires:

SQL
→ ORM
→ struct
→ reflection
→ map
→ conversion
→ JSON
→ copy
→ socket

then high load quickly turns into a fight against your own architecture.

If the request looks more like:

request
  ↓
index
  ↓
memory
  ↓
bytes
  ↓
socket

then suddenly a single ordinary machine can perform tens of thousands of these operations every second.

And if you remove the response body and leave only the internal processing, you get more than 100,000 requests per second.


Final Numbers

On the current hardware, the experiment demonstrated:

Full GET

≈55,000 RPS

with:

  • 5,800 concurrent requests;
  • real JSON;
  • real business logic;
  • filtering;
  • sorting;
  • index operations;
  • 0.0% errors.

HEAD

102,339 RPS

with the same internal request processing and 0.0% errors.

Extreme concurrency

105,800 concurrent requests

without the server crashing and without errors.

Load

Millions of requests per minute.

And after a certain level of concurrency, the system doesn't start dying.

It simply says:

"I can't go any faster. But I'm not going to fall over either."

And perhaps that's the best result a load test can give you.

Not the biggest number.

But a predictable plateau.

Because a server that turns into a pumpkin when you go from 5,000 to 100,000 concurrent requests is a problem.

A server that says:

"My ceiling is here. Beyond that, things will simply get slower."

is already a system you can reason about, plan around, and scale.

And that leaves the most interesting question:

If the algorithms are no longer the bottleneck, JSON is already on a diet, and the CPU still has room — how far can we go if the next optimization target is no longer the code, but the path the bytes take from memory all the way to the network interface?

Posted Aug 19, 2026, 12:59 AM UTC

11
Loading comments…