← Back to list

What, Why, and When to Use Cache-Control Headers as a Backend Dev?

Hey guys, how’s it going? Let’s talk about the Cache-Control header. If you’re a backend dev, I think you should know about this cache…

Minhajul Islam (Minhaj) · 2025-12-19 06:08 · 7 claps · 6.8 min read
#cache #cache-control #backend-development #backend #software-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

What, Why, and When to Use Cache-Control Headers as a Backend Dev?

Hey guys, how’s it going? Let’s talk about the Cache-Control header. If you’re a backend dev, I think you should know about this cache stuff.

Note: I’m gonna use NestJS for showing the code, okay?

When we build any endpoint, you can set a bunch of things in the header. There, we can add the Cache-Control header. This Cache-Control header will be handled by default by the browser, like frontend devs won’t do anything. Even if frontend devs don’t know about this, that’s fine. You’ve got to ensure which endpoint you give the cache or no cache to.

So, we’re clear that the Cache-Control header is only handled by the backend dev, and it only works with the browser. Hey, don’t worry — we’ll describe how it happens, and you’ll see code and images.

Let’s see how to set the Cache-Control, okay?

@Controller('/api/v1/todos')
export class TodosController {
  constructor(private readonly todosService: TodosService) {}

  @Get()
  @Header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
  findAll() {
 console.log('findAll hit! -> ' + Date.now());
    const result = this.todosService.findAll();
    return result;
  }
}

In NestJS, we can add it like this: ***@Header(‘Cache-Control’, ‘public, max-age=60, stale-while-revalidate=300’)***. If you choose any framework or raw language, there’s an option to set the Cache-Control.

Let’s figure out what’s happening in this line: ‘Cache-Control’, ‘public, max-age=60, stale-while-revalidate=300’. As you can see, it’s simple — like a key and value type. Cache-Control is the key, but there are a bunch of values, right?

How it works with other keys and values.

public

public allows the response to be cached by any cache, including shared ones like CDNs or proxies, for data that’s the same for everyone (like, static assets). This boosts performance by letting CDNs serve it quickly without always hitting your server.

private

private restricts caching to the user’s browser only, preventing shared caches from storing personalized or sensitive data to avoid privacy leaks.

max-age

As you can see, the next value is max-age=60. What is this actually? Let’s deep dive. As you can see, max-age=60 is also like a key and value, right? Whenever you add this to any endpoint header, and you’re in the browser, then hit that endpoint. When you get the result, the browser will set the header value. After setting the value in the header, the browser will set the time. The browser now knows that with max-age=60 (that’s 60 seconds), if you fetch the data at, say, 10:00 AM, the browser won’t send a request to your server until 60 seconds later (around 10:01 AM).

But there’s a condition: If you reload the page with F5 or Ctrl/Cmd+R, it still won’t request the server if within the max-age time — it serves from cache.

But if you clear all site cookies or do a hard refresh like Ctrl/Cmd+Shift+R, it bypasses the cache and requests again. If you don’t do that, it won’t request the server until the time is over.

stale-while-revalidate

Let’s talk about the stale-while-revalidate=300. It’s very important to know how it works, okay?

As you can see, the key is stale-while-revalidate — you know what it means.

This key works simply: When the browser makes a request to an endpoint, if that endpoint has stale-while-revalidate, then the browser can use it as a grace period after max-age expires. It’ll allow serving stale (outdated) content for that time (300 seconds here) while revalidating in the background.

I think you’re thinking, what’s going on here? The max-age value also caches in the browser, as well as the stale-while-revalidate time — what’s going on here?

Yes, if you’re thinking this, then you got the point, bro.

The browser’s max-age doesn’t force a hard reload — as you saw in the max-age section, how it works. But the key difference here is that stale-while-revalidate keeps the browser able to serve from cache even after the max-age time ends, for the specified seconds. The browser knows that — then what happens?

Here is the tricky part: When the browser’s max-age time finishes, it still has that endpoint value in the browser cache, but it’s now considered stale.

But the thing is, when max-age finishes, and you reload (or request again), the browser can immediately serve the stale response from disk cache to the frontend for quick results, while in the background, it sends a request to the server to revalidate. The server will respond, and if anything changes (via ETag check, but we’ll see explore later), it will update the cache quietly. If nothing changed, it just refreshes the freshness.

It’s kinda an optimization for better UX — users don’t wait, even if data might be slightly outdated during that window, okay?

Let’s see some code and images — it’ll give a clear picture, guys.

See the cache that we added ‘Cache-Control’, ‘public, max-age=60, stale-while-revalidate=300’, right?

Here you can see a bunch of things, right? First time visit, then I reload within 60 seconds, so that’s why it shows from disk cache. How many times does it take to fulfill the request? Only one — 1ms. Imagine first time visit takes 11ms, but within 60 seconds, I only reload (not hard reload).

If you practice by making a server and client, then you’ll see the same result.

We just covered the max-age; let’s see the other part: stale-while-revalidate.

To be honest, it’s tough to understand, you guys, because if you’re not practicing, then you can’t feel this scenario. Please try to practice — then you can understand.

What’s actually happening: As usual, I visit the first time — it takes 18ms. Then I wait for the max-age time to finish. After the 60 seconds, I refresh again (not a hard reload) — it shows me the result within 1ms, as you can see in the image, right?

But here is where stale-while-revalidate plays a role: It allows the browser to serve the stale response from cache for up to 5 minutes (300 seconds) after max-age expires, while sending a background request to the server to check for updates.

Now you can see only two times: findAll hit! -> 1765865589216 (1st time) findAll hit! -> 1765865665998 (last time)

1765865665998–1765865589216 = 76782 ms = 76 seconds

Why only two hits in the server? Because when max-age finishes, the browser serves from cache (stale-while-revalidate) but triggers a background request to the server. If there’s no modification (via ETag), it updates the cache quietly and starts a new max-age period. That’s why, as you can see, I’ve reloaded 4 times in the browser, but only two logs in the terminal — the first initial hit, and one revalidation after the initial max-age.

must-revalidate

@Header('Cache-Control', 'private, max-age=30, must-revalidate')

@Header('Cache-Control', 'private, max-age=30, stale-while-revalidate=100, must-revalidate')

What’s must-revalidate and how does it work? You know the meaning of must-revalidate, right?

It acts similarly to max-age, but must-revalidate makes sure that after the max-age time finishes, the next request must revalidate with the server — ensuring freshness by checking before serving from cache.

It’s simple: Both work the same during max-age, but without must-revalidate, caches might serve stale content if they can’t connect; with it, they enforce the check (potentially erroring if it fails).

stale-while-revalidate isn’t just a shadow — if you include it, it allows serving stale content quickly while revalidating in the background. If you want that UX boost, keep it; otherwise, remove it for stricter behavior — no problem.

immutable

I tried to figure out this key, but I couldn’t find any good information. I don’t know why we need this key.

@Header('Cache-Control', 'public, immutable')

@Header('Cache-Control', 'public, max-age=3600, immutable')

If you find anything about this, just add a comment; I’ll check what this thing is needed for. max-age acts similarly here; nothing changes.

no-cache

no-cache doesn’t mean “don’t cache”. no-cache allows caches to store a response, but requires them to revalidate it before reuse.

@Header('Cache-Control', 'no-cache')

That means when you first hit any endpoint, the browser will see the no-cache directive and cache the response. But when you do any reload, it will request the server to check if the data has changed or not. If it has changed, it will update the response in the cache.

no-store

The no-store response directive indicates that any caches of any kind should not store this response.

@Header('Cache-Control', 'no-store')

No matter what, if you do reload, the browser will request the server and get the fresh data.

Conclusion

In summary, the Cache-Control header is a powerful backend-driven mechanism for improving performance and user experience by controlling how browsers (and other caches) store and reuse responses.

Used correctly, these directives reduce unnecessary server hits, speed up responses, and strike a balance between performance and data accuracy — often without requiring.

I hope you guys clear the concept of the Cache-Control header!

I’ve written and implemented the ETag article; next, I’ll publish it, guys!

I’m also trying to learn and implement, and write down what this topic is!

I could be mistaken; just hit the ***LinkedIn or [Email](http://minhajul.minhaj.islam@gmail.com)***.


메타데이터
post_id
618e742b1dfd
slug
what-why-and-when-to-use-cache-control-headers-as-a-backend-dev-618e742b1dfd
url
https://medium.com/@minhajul-im/what-why-and-when-to-use-cache-control-headers-as-a-backend-dev-618e742b1dfd
canonical_url
https://medium.com/@minhajul-im/what-why-and-when-to-use-cache-control-headers-as-a-backend-dev-618e742b1dfd
author_url
https://medium.com/@minhajul-im
status
ok
fetched_at
2026-08-10 23:13:24