← Open in the full interactive course (progress tracking, search & more)

The fastest way to answer a question is to not ask it a second time.

Your homepage shows a "top 10 products" list. Computing it means querying every order in the last 30 days, grouping by product, summing quantities, and sorting the result — a query that takes 400ms and hits the database hard. That would be fine if one person loaded the homepage once. But ten thousand people load it every minute, and the underlying data — what's popular this month — barely changes between one request and the next. You are doing the exact same expensive work, over and over, to produce the exact same answer, thousands of times an hour.

Caching exists to fix exactly this: do the expensive work once, remember the answer, and hand out the remembered answer to everyone who asks next — until it's no longer trustworthy.

In this lesson, you'll learn IMemoryCache (in-process caching), the newer output caching middleware, why [ResponseCache] is a different and more limited tool than it sounds, and — the genuinely hard part — how to think about cache invalidation without shipping stale data to your users.

What Is It?

The Simple Explanation

Caching means storing the result of expensive work somewhere fast, and checking that storage before redoing the work. If the answer is already sitting there and still trustworthy, you skip the expensive part entirely and hand back the stored copy.

This lesson is about caching inside one running instance of your ASP.NET Core application — memory that lives inside your app's process, on the one machine that process happens to be running on right now. That scope matters, and the next lesson exists specifically because of it.

The Technical Definition

ASP.NET Core gives you two genuinely different in-process caching tools, aimed at two different layers of your application:

IMemoryCache

Output Caching (.NET 7+)

Both live and die inside the memory of a single running instance of your app. Neither one is shared with any other copy of your app that might be running elsewhere — that distinction is the entire subject of the next lesson.

Why Does It Exist?

The Problem — Doing Expensive Work Nobody Needs Fresh

Some data is expensive to produce: a complex aggregate query, a call to a slow third-party API, a computation that takes real CPU time. And some of that data is read far more often than it actually changes — a product catalog page, a list of countries for a dropdown, an exchange rate that updates once an hour, a "top products" widget recomputed nightly by a batch job but read thousands of times a minute. Recomputing or refetching that answer on every single request wastes CPU, database load, and time — and the honest truth is that most of those requests would have gotten the exact same answer as the one before it.

The Solution — Remember the Answer, For a While

Caching trades a small amount of memory for a large amount of avoided work. Compute or fetch the answer once, store it, and serve the stored copy to every request that arrives while it's still considered fresh. When it's no longer fresh — because it expired, or because you know the underlying data changed — you let the next request pay the real cost again, get the new answer, and cache that instead.

Big Picture

WITHOUT CACHING

Request 1 → run expensive query (400ms) → return result
Request 2 → run expensive query (400ms) → return result   (same result!)
Request 3 → run expensive query (400ms) → return result   (same result!)
    ↓
Database hammered with identical, avoidable work


WITH CACHING

Request 1 → cache empty → run expensive query (400ms) → store result → return result
Request 2 → cache HIT → return stored result (< 1ms)
Request 3 → cache HIT → return stored result (< 1ms)
    ↓ (after expiration, or explicit invalidation)
Request N → cache MISS → run query again → store fresh result → return result

The performance win is real and often dramatic. But notice the trade you just made: for a window of time, some requests are being served an answer that is not a live, real-time query against the true underlying data — it's a snapshot from whenever it was cached. Managing that gap between "cached" and "true right now" is what the rest of this lesson is really about.

How It Works

IMemoryCache — THE CACHE-ASIDE SHAPE
1. REGISTER IT ONCE, AT STARTUP
builder.Services.AddMemoryCache();
2. TRY TO GET THE VALUE FROM THE CACHE FIRST
if (!cache.TryGetValue(cacheKey, out Product? product))
{
    // MISS — fall through to step 3
}
3. ON A MISS, DO THE REAL WORK, THEN STORE IT
product = await dbContext.Products.FindAsync(productId);
cache.Set(cacheKey, product, TimeSpan.FromMinutes(5));
4. FUTURE REQUESTS HIT THE CACHE UNTIL IT EXPIRES

Simple Example

The classic IMemoryCache pattern — often written with GetOrCreateAsync, which combines the "check, then populate" steps into one call:

public class ProductService(AppDbContext db, IMemoryCache cache)
{
    public async Task<Product?> GetProductAsync(int productId)
    {
        var cacheKey = $"product:{productId}";

        return await cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return await db.Products.FindAsync(productId);
        });
    }
}

Line by line: GetOrCreateAsync checks whether cacheKey already has a value. If it does, that value is returned immediately — the lambda never runs. If it doesn't, the lambda runs, sets an expiration on the new entry, and its return value both gets stored in the cache and returned to the caller. Every subsequent call with the same productId, within five minutes, skips the database entirely.

Real-World Example

Output caching goes a level higher: instead of caching one piece of data inside your method, it caches the entire rendered HTTP response for a route, so a repeat request never even reaches your endpoint's code:

// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("ProductCatalog", policy =>
        policy.Expire(TimeSpan.FromMinutes(2)).Tag("catalog"));
});

var app = builder.Build();
app.UseOutputCache(); // must come after routing, before endpoints run

app.MapGet("/api/products", async (AppDbContext db) =>
{
    // On a cache HIT, this handler never executes at all.
    return await db.Products.OrderBy(p => p.Name).ToListAsync();
})
.CacheOutput("ProductCatalog");

The first request runs the handler, queries the database, and caches the full serialized response for two minutes, tagged "catalog". Every request in that window — from any client — gets the cached response served directly by the output caching middleware, with zero of your endpoint code, and zero database round-trips, running again. The Tag matters: it gives you a handle to invalidate this cached response explicitly (via IOutputCacheStore.EvictByTagAsync("catalog", ...)) the moment a product actually changes, rather than waiting out the full two minutes.

Output caching vs. [ResponseCache]: The older [ResponseCache] attribute is often mistaken for server-side caching — it isn't, primarily. It mainly sets HTTP response headers like Cache-Control and Expires, which are instructions to the browser and any intermediate proxies about how long they're allowed to reuse the response, without contacting your server again at all. Your server still runs the endpoint on every request that does reach it. Output caching middleware is different in kind: your own server actually stores the rendered response and serves it back directly, without a client-side or proxy cache being involved at all.

Analogy

The Receptionist's Sticky Note

Imagine a receptionist who gets asked "what's today's exchange rate?" fifty times an hour. Instead of calling the finance desk every single time, she calls once, writes the answer on a sticky note, and reads from the note for the next hour. Anyone who asks gets a fast, correct-enough answer without a phone call.

But the note has a problem: if the rate changes mid-hour and nobody tells her, she keeps handing out the old number — confidently, and wrongly — until either the hour is up (expiration) or someone walks over and updates the note (explicit invalidation). The note is fast precisely because it isn't the live source of truth; that's the whole trade caching makes.

Under the Hood

WHY IMemoryCache IS "PER INSTANCE"
1. IT'S A REGULAR .NET OBJECT LIVING IN PROCESS MEMORY
2. THAT MEMORY DIES WHEN THE PROCESS DOES
3. IT NEVER KNOWS ABOUT ANY OTHER RUNNING COPY OF YOUR APP

Common Confusion

"There are only two hard problems in computer science"

You'll hear a version of an old programmer joke, usually attributed to Phil Karlton: "There are only two hard things in computer science: cache invalidation and naming things." It's funny because it's true — cache invalidation is a genuinely, nontrivially hard problem, not a beginner-level afterthought. The hard part isn't storing the value; it's knowing, correctly and reliably, the exact moment a cached value stops being an accurate reflection of reality. Get it wrong and you don't get a crash — you get a system that runs perfectly and quietly returns wrong answers.

"[ResponseCache] caches on the server" — usually not

As covered above, [ResponseCache] primarily emits caching headers. Its Location = ResponseCacheLocation.Any option can, in specific hosting configurations, enable server-side caching via a separate response caching middleware — but that's the exception, not what most people mean when they reach for the attribute. If your goal is genuine server-side caching of full responses, output caching middleware is the direct, modern tool for that job.

Common Mistakes

Mistake 1 — Caching with no expiration at all

cache.Set(key, value) with no expiration means the entry lives until it's explicitly removed or evicted under memory pressure — an easy way to silently serve data that's hours or days stale.

Always set an explicit expiration appropriate to how quickly the underlying data can realistically change — even a short one is a safety net.

Mistake 2 — Caching data that changes per-request but keying it too broadly

Caching a "current user's cart total" under a single shared key that doesn't include the user's ID — every user ends up seeing whichever user's total got cached first. This is a correctness bug, not just a staleness one.

Build cache keys from everything that makes the value unique: $"cart-total:{userId}", not just "cart-total".

Mistake 3 — Caching data that changes on almost every write

Wrapping a value in a cache when the write-to-read ratio is roughly 1:1 — you pay all the complexity of managing cache invalidation and get almost none of the performance benefit, because the cache is invalidated about as often as it would have been recomputed anyway.

Reserve caching for data that's read far more often than it's written — that's where the trade genuinely pays off.

When Should I Use It?

Caching genuinely helps when:

Caching is the wrong tool — or a dangerous one — when:

Looking ahead: Everything in this lesson lives inside one running instance of your app. The moment your app scales out to more than one instance behind a load balancer, each instance's IMemoryCache becomes its own island — invisible to every other instance. That's exactly the problem the next lesson, Distributed Caching, solves.

Mental Model

IMemoryCache = a fast, in-process sticky note for values your code computes
Output caching = a sticky note for an entire HTTP response
[ResponseCache] = mostly a note asking the browser/proxy to keep its own sticky note
Expiration = "trust this note for this long, no matter what"
Invalidation = "throw the note away right now, the real answer changed"

Remember: a cache entry with no expiration and no invalidation plan isn't a cache — it's a slowly rotting copy of the truth.

Key Takeaway


Check Your Understanding

You've seen how in-process caching works and why invalidation is the real challenge. Let's check your reasoning.

1. A dashboard endpoint runs an expensive aggregate query that produces the same result for every user for about 10 minutes at a time. What's the strongest reason to cache it with IMemoryCache?

Show answer

Correct: B

Why B is correct: This is exactly the profile where caching pays off — expensive to produce, read constantly, changes rarely relative to how often it's requested. Caching it once and serving that answer for a bounded window avoids massive repeated cost for no loss of meaningful accuracy.

Why A is incorrect: Caching doesn't speed up the query itself — it avoids running the query again at all, which only helps because the result would be identical anyway.

Why C is incorrect: There's no such built-in requirement or rule in ASP.NET Core.

Why D is incorrect: IMemoryCache is explicitly per-instance, not shared — that's the whole reason the next lesson on distributed caching exists.

Reinforcement: Cache when the cost of producing an answer is high and the answer doesn't change often relative to how often it's asked for.

2. What is the main practical difference between the [ResponseCache] attribute and ASP.NET Core's output caching middleware?

Show answer

Correct: B

Why B is correct: [ResponseCache] is primarily about instructing external caches (browser, CDN, proxy) via headers like Cache-Control — your server still typically runs the endpoint on every request that reaches it. Output caching middleware genuinely stores the rendered response server-side and can serve it back without your endpoint code running again at all.

Why A is incorrect: They solve related but meaningfully different problems — client/proxy hinting versus real server-side response storage.

Why C is incorrect: Output caching works with both Minimal APIs and MVC controllers.

Why D is incorrect: Neither is inherently database-backed by default; the distinction is about headers-to-clients versus actual server-side response storage, not storage medium.

Reinforcement: "Caches the response" can mean very different things depending on whether the caching happens on the server or is merely requested of the client.

3. A developer caches a user's account balance for 10 minutes to reduce database load on a busy balance-check endpoint. What's the most serious risk with this specific choice?

Show answer

Correct: B

Why B is correct: This is exactly the "staleness has real consequences" case from the lesson — an account balance is the kind of value where showing outdated data can cause genuine problems (a user overdrafting believing they have more funds than they do, for example). This is a case where caching needs, at minimum, aggressive invalidation on write — or shouldn't be cached this way at all.

Why A is incorrect: IMemoryCache can store any object, decimals included — that's not a real limitation.

Why C is incorrect: Caching speeds up reads on a cache hit; the concern here is correctness, not speed.

Why D is incorrect: There is no universally "safe" expiration — the right duration depends entirely on how much staleness the specific data can tolerate, and financial data tolerates very little.

Reinforcement: The right question before caching anything isn't just "is this expensive?" — it's also "what happens if someone sees a stale version of this?"

4. Which of these is a genuine, standard strategy for dealing with cache invalidation, as covered in this lesson?

Show answer

Correct: B

Why B is correct: These are the real, practical tools the lesson covers: bound staleness with expiration, actively evict/replace an entry the moment you know its source data changed, and design keys so that a data change naturally produces a different key (avoiding the old value being read at all).

Why A is incorrect: No expiration is one of the common mistakes covered — it doesn't solve staleness, it just makes it unbounded and easy to forget about.

Why C is incorrect: This "solves" staleness by discarding all caching benefit and adding operational chaos — not a real strategy.

Why D is incorrect: Invalidation is hard, not impossible — the lesson's whole point is that there are real, workable strategies, they just require deliberate design rather than being automatic.

Reinforcement: Cache invalidation is hard specifically because it requires you to actively think about staleness, not because there's no way to manage it.

You now understand in-process caching with IMemoryCache and output caching — and why cache invalidation is the part that actually takes engineering judgment. Next: what happens once your app runs as more than one instance.


dotnetmadeeasy.com — Learn C# and .NET, the right way.