DEV Community

Cover image for Why REST Scales, and the Three Ways It Bites Back
MANGESH MANDLIK
MANGESH MANDLIK

Posted on

Why REST Scales, and the Three Ways It Bites Back

Say you are building a product page. The mobile team asks for one small thing: a heart icon that shows whether the current user has wishlisted the item. Easy. You add "is_in_wishlist": true to the product response, ship it, and go get lunch.

Meanwhile, your CDN has been caching that product response for five minutes, because that's what you told it to do. The first user to load the page had the item in their wishlist, so the CDN stored a response saying true and started handing it to everyone else. Strangers now see a filled-in heart on something they never touched.

There's no exception, no alert and no failed request. Every response was a clean 200. The system did exactly what the contract said, and the contract was wrong.

That's a REST bug, and it's a good way into what REST actually is, because most of us learned it as "use nouns in your URLs" and never got the part about why. I also built an animated walkthrough of this, REST on SeeItFlow, that covers stateless scaling, CDN caching and session bugs scene by scene. Here's the written version.

REST is a set of rules for the stuff in the middle

Go back to 1998. An API call was simple: one browser talked to one server. Nothing sat between them worth mentioning.

Two years later Roy Fielding published the dissertation that defined REST. It described a style, not a protocol, and the timing turns out to matter. By 2005, a request to a typical service didn't go straight to your code. It passed through load balancers, reverse proxies, CDNs and API gateways. Today it might come from a web app, a phone, a partner's backend or a smart thermostat, and still travel through the same chain.

Every one of those hops has to decide what to do with a request it knows nothing about. Should it be cached? Is it safe to retry? Can any server handle it? None of them can read your application code, so they need rules that are the same everywhere. That's what REST really provides: a shared language between your app and everything in front of it.

Which leads to the sentence I'd put at the top of any REST explanation: REST scales because intermediaries understand it, not because it happens to run over HTTP. HTTP is just the vocabulary. The value is that a CDN, a proxy and a gateway can all read the same request and make the right call without asking you.

What "understanding" looks like

Here's one ordinary request and response:

GET /orders/42 HTTP/1.1
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 200 OK
Cache-Control: max-age=60
Content-Type: application/json

{ "id": 42, "status": "paid", "amount": 1999 }
Enter fullscreen mode Exit fullscreen mode

Nearly every line is doing work for somebody other than your application code. The method and URL say what's being asked for, and that GET means it's a read and safe to repeat. The Authorization header carries the caller's identity with the request, so no server needs to remember them from before. The 200 tells every hop that this worked. And Cache-Control: max-age=60 tells any cache along the way that it may serve this exact response for the next minute without bothering your origin.

Notice what's missing: any dependence on which server answers. That's the first of the two constraints that do most of the practical work.

Statelessness: any server can answer any request

Stateless means each request carries everything the server needs to handle it. There's no session sitting in one server's memory and no requirement that a user keep returning to the same machine.

The payoff is that horizontal scaling gets almost boring. Need more capacity? Add servers. The load balancer can send any request to any of them, and no coordination is required.

You feel the value most when you lose it. Suppose the session lives in memory on Server A, and the load balancer routes the user's next request to Server B. Server B has never heard of them and returns a 401. The usual patch is sticky sessions, which pin each user to one server, and it works, but you've quietly given up the property you were scaling for. A server dying now logs out everyone stuck to it.

Stateless auth has its own catch, and it's a good example of REST's constraints costing something real. Token-based auth (JWTs, for example) is stateless by design, so any server can verify a token without looking anything up. That's exactly why you can't revoke one early. Picture a compromised account. You want that user logged out right now, but the token is valid for 24 more hours, and no server has any record it could check.

There isn't a clever way out. The pragmatic answer is short-lived access tokens, somewhere around 5 to 15 minutes, plus refresh tokens that the server does track and can revoke. The other option is a denylist of revoked tokens, but be honest with yourself about what that is: it's server-side state again. You're trading a bit of statelessness for the ability to say "no" quickly, which is a perfectly reasonable trade as long as you make it on purpose.

Cacheability: the fastest request never reaches your database

The second constraint is that reads should be cacheable. A GET response marked with Cache-Control can be stored by browsers, proxies and CDNs. A product page that gets a million reads a day should reach your database close to zero times once the cache is warm.

That's an enormous win for something you get almost for free, but it comes with one rule that the opening story broke: never put user-specific data in a response a shared cache can store.

The CDN has no idea is_in_wishlist is personal. It sees a GET to a product URL with a max-age, so it stores the first response it gets and serves it to everyone. The fix is to split the data by who it belongs to:

GET /products/981
→ 200 OK
Cache-Control: public, max-age=300
{ "id": 981, "name": "Trail Runner 2", "price": 89 }

GET /me/wishlist
→ 200 OK
Cache-Control: private, no-store
{ "product_ids": [981, 1204] }
Enter fullscreen mode Exit fullscreen mode

The product is the same for everyone and can sit in a CDN all day. The wishlist belongs to one person, and private tells shared caches to leave it alone. The client stitches the two together and the heart icon lights up for the right people.

The third bite: retries and PATCH

There's one more incident worth knowing, and it comes from a different direction. A client sends PATCH /orders/42 with { "quantity": "+1" }. The gateway times out. The client, doing exactly what it should, retries. Now the server has applied the increment twice, and the customer has two extra items.

The cause is that a delta ("add one") isn't idempotent, so doing it twice isn't the same as doing it once. The cure is to describe the target state instead:

# Retry-unsafe: every retry changes the result
PATCH /orders/42   { "quantity": "+1" }

# Retry-safe: send it ten times and the order looks the same
PATCH /orders/42   { "quantity": 5 }
Enter fullscreen mode Exit fullscreen mode

If you truly need delta semantics, an idempotency key on the request lets the server recognise the retry and replay the original result instead of applying it again.

This is the same family of problem as the cache bug. HTTP gives clients, proxies and gateways permission to retry and cache based on what your API appears to promise. If the behaviour of your endpoint doesn't match what the method implies, they'll act on the promise, not on the truth.

Look at what "RPC in a REST costume" costs you

You've seen this style of API:

POST /createOrder
POST /getOrder
POST /cancelOrder
POST /updateOrder
Enter fullscreen mode Exit fullscreen mode

It works, but every action needs its own endpoint, so the surface grows with every feature. More importantly, everything is a POST, and to a cache or a gateway a POST means "this changes something, don't touch it." You've thrown away caching and retry behaviour that you'd otherwise get automatically.

The resource-shaped version needs one noun and four operations, and each response has a code that the machinery understands. A POST /orders returns 201 Created with a Location header pointing at the new order. GET /orders/42 returns 200, is cacheable and is safe to repeat. PATCH /orders/42 returns 200 with the updated state. DELETE /orders/42 returns 204 No Content, because there's nothing left to send back.

None of that is about style. Each choice lets some piece of infrastructure between you and the client do its job.

Most "REST" APIs live at Level 2, and that's fine

There's a useful yardstick called the Richardson Maturity Model, which grades APIs from 0 to 3. At Level 0, everything goes to one endpoint with an action in the body, like POST /api with a payload that says what to do. At Level 1, resources exist (/orders, /users) but everything is still a POST. At Level 2 you use proper HTTP methods and status codes. At Level 3, responses also include links to the valid next actions, which is called HATEOAS.

The vast majority of real-world APIs called REST sit at Level 2. Purists will tell you Level 3 is required to call something REST at all, and Level 3 is rarely built in practice. I wouldn't lose sleep over it. Level 2 is where you collect the benefits that matter here: cacheable reads, safe retries, and codes that intermediaries can interpret.

Where REST is the wrong tool

REST is request and response. That shapes what it's good at, and it's worth being direct about where it isn't.

Streaming and real-time. REST can't push. If the server needs to send data as it happens, Server-Sent Events handle one-way streams and WebSockets handle two-way. Simulating either with polling works until it doesn't.

Internal microservices. When services call each other thousands of times a second, per-request overhead adds up. gRPC, with binary Protobuf messages over HTTP/2, is built for exactly that workload.

Flexible client queries. When a mobile screen has to make three round-trips and throw away most of each response to render one view, that's over-fetching. GraphQL lets the client ask for precisely what it needs.

Graph traversal. Following relationships across many resource types means many round-trips or lots of custom endpoints, and REST gets awkward fast.

None of this is a criticism. Every tool has a shape, and the skill is matching it to the job.

How I'd decide

Reach for REST when your API is external, partner-facing or used from browsers. In that world, caching, auth headers, CDN acceleration and being able to debug with curl all matter a lot. If any engineer who understands HTTP should be able to pick your API up and use it, REST is the right default.

Look elsewhere when you notice yourself tunnelling every action through POST, fighting over-fetching on every mobile screen, or faking a stream with polling. That friction isn't a sign you're doing REST badly. It's the workload telling you it wants a different tool.

The mature way to make this call is to choose REST for reasons, not popularity. You pick it because HTTP cacheability and statelessness are genuinely valuable for this particular workload. When they aren't valuable, something else wins.

Where it comes together

Look back at all three incidents: the JWT you couldn't revoke, the wishlist served to strangers and the doubled quantity on a retry. Each one is a constraint being violated by accident. The token bent statelessness, the personalised response bent cacheability, and the delta bent the retry-safety that HTTP methods promise.

REST's value lives in those constraints. Statelessness makes horizontal scaling nearly free. Cacheability makes popular reads nearly free. Uniform semantics make the whole system legible to proxies, CDNs and the humans debugging them. Break those on purpose, knowing what you're paying, and that's engineering. Break them by accident and you get a heart icon on a stranger's screen.

And if you find yourself breaking them on purpose, over and over, that's worth taking seriously. It might mean REST is no longer the right tool for the job.

Explore It Visually

If you'd like to watch this rather than read it, I turned the whole thing into an animated walkthrough. It follows a request through the load balancer, shows a CDN serving and mis-serving cached responses, and steps through session bugs and POST side effects: REST on SeeItFlow.

I'd like to hear from you too. Have you been bitten by a cache serving the wrong data, or a retry that did something twice? Tell me what happened in the comments.

Top comments (0)