Architecture

Designing for millions: lessons from scaling a real-time API

When a side project suddenly has to answer a hundred thousand requests a minute, the comfortable abstractions start to leak. Here's what actually held up, and what I'd never do again.

Scale rarely breaks where you expect it to.

The morning it happened, I was making coffee. A small tool I’d built to broadcast live scores had been picked up by a popular account overnight, and by 9 a.m. it was fielding more traffic in a minute than it had seen in its entire life. Nothing was technically down; it was just slow in a way that felt personal. That day taught me more about systems than a year of reading ever had.

The thing nobody tells you about scale is that it doesn’t arrive as a single problem. It arrives as a sequence of small, smug assumptions failing one after another, each one perfectly reasonable until the moment it isn’t.

The first wall is the database, not the app

I had spent my optimization energy in the wrong place. The application code was fine; Go was barely breaking a sweat. The bottleneck was a single unindexed column being asked the same question tens of thousands of times a second. The fix took thirty seconds. Finding it took three hours, because I’d never learned to read an execution plan under pressure.

If you take one thing from this post: before you reach for another replica or a bigger box, open the query analyzer. The database almost always knows exactly what’s wrong, and it will tell you for free.

Cache like you actually mean it

The naive cache makes everything faster right up until a popular key expires and a thousand requests stampede the database at once. The fix is to collapse those duplicate requests into a single in-flight load, which is what Go’s singleflight package does in about a dozen lines:

// Get returns a value, collapsing concurrent misses
// for the same key into one trip to the database.
func Get(ctx context.Context, key string) ([]byte, error) {
    if v, ok := cache.Load(key); ok {
        return v.([]byte), nil
    }
    v, err, _ := group.Do(key, func() (any, error) {
        return loadFromDB(ctx, key)
    })
    if err != nil {
        return nil, err
    }
    cache.Store(key, v)
    return v.([]byte), nil
}
One singleflight.Group turned a thundering herd into a single, polite knock.

The goal of a cache isn’t to be fast. It’s to protect the thing behind it from ever being asked the same question twice.

Backpressure is a feature, not an apology

My instinct was to accept every request and try heroically to serve it. That’s how you turn a slow service into a dead one. The healthier posture is to decide, early and explicitly, what you will refuse. A request rejected in two milliseconds with a clear 429 is a far kinder answer than one that hangs for thirty seconds and then times out anyway.

Once I started treating rejection as a first-class outcome, the whole system got calmer. A few things made the biggest difference:

⬢ → ⬢ → ⬡
The final shape: edge cache, a thin API, and a database that’s allowed to say no.

What I’d never do again

I’d never again confuse “it works on my machine” with “it works.” Local development is a single user with infinite patience and zero latency: the three conditions least like production. The bugs that matter live in the gap between them, and the only way to find them is to put real load on the thing before the internet does it for you.

The little scoreboard still runs today, quietly, on hardware that costs less than lunch. It’s not impressive to look at. But it stays up when it’s busy, and after that one chaotic morning, I’ve learned not to take that for granted.


M

Mo Jahani

Software architect working on systems for millions of users, from embedded devices to web platforms. I write about the unglamorous engineering decisions that decide whether software lasts.

Get in touch