Skip to main content

Server-Side Caching with HybridCache – Web API | Lesson 91

Published / updated

Introduction​

Some SMS data changes once a year, such as the current academic year. Some changes a few times a term, such as the list of subjects. Yet the API asks SQL Server for it on every request.

A cache keeps a copy of an answer close by, so the next request gets it without asking again. This lesson caches the SMS's master data and the dashboard with .NET's HybridCache.

By the end of this lesson, you will be able to:

  • Decide what is worth caching, and for how long.
  • Add caching with a decorator, without changing controllers or services.
  • Use HybridCache.GetOrCreateAsync with expiry times and tags.
  • Remove stale answers when the data changes.
  • Measure the difference, and explain the risks of caching.

Why this matters​

At the start of a school day, a thousand teachers take attendance within an hour. Each request first looks up the current academic year. That is a thousand identical questions with the same answer.

Caching cuts that to one question per hour. The database has more time for the work that really changes, and every screen answers faster.

The concept​

Think of the timetable pinned on the staff-room wall. Teachers read the copy on the wall instead of asking the office every time. When the timetable changes, the office takes down the old copy and pins up the new one.

Example: what to cache​

DataChangesCache forWhy
Current academic yearonce a year1 hourread by almost every request
Subjectsrarely, by the admin1 hour, removed when one is addedevery screen shows them
Classes with student countswith every enrollment5 minutescounts may be a little old
Dashboard for a dateconstantly1 minutemany people open it; a minute is fine
A student's marks or feesany timenot cachedmust always be exact

The rule: cache when the answer is read far more often than it changes, and when a slightly old answer does no harm. If either half is false, leave it uncached.

Example: HybridCache​

HybridCache is .NET's cache for this job. Its main method, GetOrCreateAsync, takes a key and a function:

  • If the key is in the cache, it returns the stored answer.
  • If not, it runs the function once, stores the answer, and returns it.

Two more features matter in a busy school:

  • Stampede protection. When a hundred requests ask for the same missing key at once, the function runs once and all hundred share the answer.
  • Tags. Entries can carry tags, and RemoveByTagAsync("subjects") removes every entry with that tag.

"Hybrid" means it can use two levels: memory in each server, plus an optional shared cache such as Redis for several servers. The SMS uses memory only, which needs no extra setup.

With several servers and memory only, each server keeps its own copy. Removing a tag on one server does not remove it on the others, so their copies stay until they expire.

That is one reason the SMS keeps expiry times short for data that changes. With a shared second level, every server reads the same copy, and the cost is one more system to run.

Example: browser caching and server caching​

Lesson 76 already added [ResponseCache], which sends a Cache-Control header. That is a different cache:

[ResponseCache] (Lesson 76)HybridCache (this lesson)
Where the copy livesin each browser or proxyin the API's memory
Who benefitsone client, asking againevery client, including the first one each hour
Can the API remove it earlyno: it is on the clientyes: by key or by tag
Good forpublic answers such as subjectsanything the server asks the database for often

They work well together. The browser skips the request altogether, and when it does ask, the API answers from memory.

Example: choosing an expiry time​

An expiry time is a promise about how old an answer may be. A long expiry saves more database work but risks older data; a short one keeps data fresh but saves less.

Choose it from the data, not from habit: ask how often the data really changes, and what happens if someone sees the old version for that long.

For student counts, five minutes of old numbers harms nobody. For a fee balance, even one minute could make a parent pay twice, which is why the SMS never caches money.

Example: the decorator pattern​

A decorator is a class that implements an interface by wrapping another class with the same interface. The cached class implements IMasterDataRepository and holds the Dapper repository inside.

Everything that asks for IMasterDataRepository, such as the attendance and marks services, now gets the cached version, and none of them changes. This is the same idea as Lesson 81, where interfaces let the SMS swap EF Core for Dapper.

Apply it to the SMS​

Step 1: the cached master data​

src/SchoolApp.Api/Caching/CachedMasterDataRepository.cs
using Microsoft.Extensions.Caching.Hybrid;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Caching;

// Lesson 91: a decorator. It implements IMasterDataRepository by wrapping the real (Dapper) repository:
// answers that rarely change come from the cache, everything else is passed straight through.
// Nothing that uses IMasterDataRepository changes; only the DI registration in Program.cs does.
public class CachedMasterDataRepository : IMasterDataRepository
{
public const string SubjectsTag = "subjects";

private static readonly HybridCacheEntryOptions OneHour = new() { Expiration = TimeSpan.FromHours(1) };
private static readonly HybridCacheEntryOptions FiveMinutes = new() { Expiration = TimeSpan.FromMinutes(5) };

private readonly IMasterDataRepository _inner;
private readonly HybridCache _cache;

public CachedMasterDataRepository(IMasterDataRepository inner, HybridCache cache)
{
_inner = inner;
_cache = cache;
}

// Academic years change once a year. The current year is read by attendance, marks, fees, and enrollment
// on almost every request, so it is the biggest win.
public async Task<List<AcademicYearDto>> GetAcademicYearsAsync(CancellationToken cancellationToken = default) =>
await _cache.GetOrCreateAsync("academic-years", async token => await _inner.GetAcademicYearsAsync(token),
OneHour, cancellationToken: cancellationToken);

public async Task<AcademicYearDto?> GetCurrentAcademicYearAsync(CancellationToken cancellationToken = default) =>
await _cache.GetOrCreateAsync("academic-years:current", async token => await _inner.GetCurrentAcademicYearAsync(token),
OneHour, cancellationToken: cancellationToken);

// Section counts change with every enrollment, so five minutes, the same as the HTTP cache (Lesson 76).
public async Task<List<ClassDto>> GetClassesAsync(CancellationToken cancellationToken = default) =>
await _cache.GetOrCreateAsync("classes", async token => await _inner.GetClassesAsync(token),
FiveMinutes, cancellationToken: cancellationToken);

// Subjects carry a tag, so adding one can throw away every cached subject answer at once.
public async Task<List<SubjectDto>> GetSubjectsAsync(CancellationToken cancellationToken = default) =>
await _cache.GetOrCreateAsync("subjects", async token => await _inner.GetSubjectsAsync(token),
OneHour, [SubjectsTag], cancellationToken);

public async Task<SubjectDto?> GetSubjectAsync(string code, CancellationToken cancellationToken = default) =>
await _cache.GetOrCreateAsync($"subjects:{code.ToUpperInvariant()}", async token => await _inner.GetSubjectAsync(code, token),
OneHour, [SubjectsTag], cancellationToken);

// A change: pass it through, then remove the answers it made out of date.
public async Task<SubjectDto> AddSubjectAsync(CreateSubjectRequest request, CancellationToken cancellationToken = default)
{
SubjectDto subject = await _inner.AddSubjectAsync(request, cancellationToken);
await _cache.RemoveByTagAsync(SubjectsTag, cancellationToken);
return subject;
}
}

The reads go through GetOrCreateAsync. AddSubjectAsync passes the change to the real repository, then removes every entry tagged subjects.

GetSubjectAsync puts the code in its key in capitals, so math and MATH share one entry.

Step 2: the cached dashboard​

src/SchoolApp.Api/Caching/CachedDashboardQueries.cs
using Microsoft.Extensions.Caching.Hybrid;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Caching;

// Lesson 91: the dashboard (Lesson 84) for one date, kept for a minute. The office opens it all day; a minute
// old is fine, and only the first request in each minute pays for the five SQL statements.
public class CachedDashboardQueries : IDashboardQueries
{
private static readonly HybridCacheEntryOptions OneMinute = new() { Expiration = TimeSpan.FromMinutes(1) };

private readonly IDashboardQueries _inner;
private readonly HybridCache _cache;

public CachedDashboardQueries(IDashboardQueries inner, HybridCache cache)
{
_inner = inner;
_cache = cache;
}

// The key includes everything the answer depends on: the year and the date.
public async Task<DashboardDto> GetSummaryAsync(AcademicYearDto year, DateOnly asOf, CancellationToken cancellationToken = default) =>
await _cache.GetOrCreateAsync($"dashboard:{year.Id}:{asOf:yyyy-MM-dd}",
async token => await _inner.GetSummaryAsync(year, asOf, token),
OneMinute, cancellationToken: cancellationToken);
}

The key, dashboard:{yearId}:{date}, names everything the answer depends on. A key that left out the date would hand yesterday's numbers to anyone asking about today.

Step 3: register the decorators​

Program.cs (the Lesson 91 lines)
builder.Services.AddHybridCache();
builder.Services.AddScoped<DapperMasterDataRepository>();
builder.Services.AddScoped<IMasterDataRepository>(provider => new CachedMasterDataRepository(
provider.GetRequiredService<DapperMasterDataRepository>(), provider.GetRequiredService<HybridCache>()));
builder.Services.AddScoped<DapperDashboardQueries>();
builder.Services.AddScoped<IDashboardQueries>(provider => new CachedDashboardQueries(
provider.GetRequiredService<DapperDashboardQueries>(), provider.GetRequiredService<HybridCache>()));

Registered later, these lines replace the ones in AddSchoolData: when a type is registered twice, the last registration wins. The Dapper classes are registered under their own names, so the decorators can ask for them.

The same change removed a warning left from Lesson 85. The package Microsoft.Extensions.Identity.Core is already part of ASP.NET Core, so its reference gave warning NU1510 and was deleted.

Step 4: before and after​

Signed in as the principal, each request ran five times:

Output
GET /api/dashboard/summary?asOf=2026-08-03 0.196 0.004 0.005 0.003 0.002 (seconds)
GET /api/dashboard/summary?asOf=2026-08-04 0.028 0.002 0.002 0.003 0.003
GET /api/classes 0.056 0.004 0.004 0.003 0.003
GET /api/subjects 0.019 0.003 0.002 0.002 0.002

Only the first call for each key paid for the database. A new date for the dashboard paid once again, because its key was new. The very first call also paid for starting up.

The savings look small per request: from 19 ms to about 2 ms for the subjects. They add up because the same request comes from every screen, all day. For the current academic year, which almost every request needs, the database now answers once an hour instead of thousands of times.

Step 5: removing stale answers​

Output
POST /api/subjects {"code":"ART","name":"Art"} Subject added.
GET /api/subjects 7 subjects. ['ART', 'CS', 'ENG', 'HIN', 'MATH', 'SCI', 'SST']

The list was cached a moment earlier with six subjects. Removing the tag made the next request read the database again, so the new subject appeared at once. The demo subject was deleted afterwards.

Step 6: tests without a database​

Show tests/SchoolApp.Tests/Unit/CachedMasterDataTests.cs
tests/SchoolApp.Tests/Unit/CachedMasterDataTests.cs
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using SchoolApp.Api.Caching;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Tests.Unit;

// Lesson 91: the cache decorator, with a fake repository that counts how often it is really asked.
public class CachedMasterDataTests
{
private sealed class CountingMasterData : IMasterDataRepository
{
public int SubjectReads { get; private set; }
public int YearReads { get; private set; }
private readonly List<SubjectDto> _subjects = [new SubjectDto(1, "ENG", "English")];

public Task<List<SubjectDto>> GetSubjectsAsync(CancellationToken cancellationToken = default)
{
SubjectReads++;
return Task.FromResult(_subjects.ToList());
}

public Task<AcademicYearDto?> GetCurrentAcademicYearAsync(CancellationToken cancellationToken = default)
{
YearReads++;
return Task.FromResult<AcademicYearDto?>(new AcademicYearDto(2, "2026-27", new DateOnly(2026, 6, 1), new DateOnly(2027, 3, 31), true));
}

public Task<SubjectDto> AddSubjectAsync(CreateSubjectRequest request, CancellationToken cancellationToken = default)
{
SubjectDto subject = new SubjectDto(_subjects.Count + 1, request.Code, request.Name);
_subjects.Add(subject);
return Task.FromResult(subject);
}

public Task<List<AcademicYearDto>> GetAcademicYearsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<List<ClassDto>> GetClassesAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<SubjectDto?> GetSubjectAsync(string code, CancellationToken cancellationToken = default) => throw new NotImplementedException();
}

private static (CachedMasterDataRepository Cached, CountingMasterData Inner) NewCache()
{
HybridCache cache = new ServiceCollection().AddHybridCache().Services.BuildServiceProvider().GetRequiredService<HybridCache>();
CountingMasterData inner = new CountingMasterData();
return (new CachedMasterDataRepository(inner, cache), inner);
}

[Fact]
public async Task CurrentYear_AskedThreeTimes_ReadsTheDatabaseOnce()
{
(CachedMasterDataRepository cached, CountingMasterData inner) = NewCache();

for (int i = 0; i < 3; i++)
{
Assert.Equal("2026-27", (await cached.GetCurrentAcademicYearAsync())?.Name);
}

Assert.Equal(1, inner.YearReads);
}

[Fact]
public async Task AddingASubject_RemovesTheCachedList()
{
(CachedMasterDataRepository cached, CountingMasterData inner) = NewCache();
await cached.GetSubjectsAsync();
await cached.GetSubjectsAsync(); // from the cache

await cached.AddSubjectAsync(new CreateSubjectRequest("ART", "Art"));
List<SubjectDto> after = await cached.GetSubjectsAsync(); // the tag was removed: read again

Assert.Equal(2, inner.SubjectReads);
Assert.Contains(after, s => s.Code == "ART");
}
}

A fake repository counts how often it is really asked. Three requests for the current year reach it once; adding a subject makes the next list read it again. The suite has 139 tests, all passing.

Common mistakes​

  1. Caching data that must be exact. A parent sees an old fee balance. Fix: cache only what may be a little old, and never per-person money or marks.
  2. A key that leaves something out. Two different questions share one answer. Fix: put every input of the answer in the key, such as the year and the date.
  3. Forgetting to remove stale entries. A new subject is missing for an hour. Fix: tags, removed by the code that makes the change.
  4. Caching per-user answers in a shared key. One user sees another's data. Fix: include the user, or do not cache it.
  5. Assuming one server. With several servers, each memory cache has its own copy. Fix: short expiry, or a shared second level such as Redis.

In real projects​

From 16 years of .NET projects

A school portal cached each student's fee summary for an hour to make the parent app faster. A father paid at the counter, opened the app, and still saw the full amount due.

He paid again online. The school then had to refund him, and the support desk had a long week.

We stopped caching money altogether and cached only master data and the office dashboard. The parent app stayed fast enough, and nobody paid twice because of a stale screen again.

Best practices​

  • Cache answers that are read often, change rarely, and may be a little old.
  • Add caching with decorators, so the rest of the code never knows.
  • Put every input in the cache key, and give every entry an expiry time.
  • Remove tagged entries in the same code path that changes the data.
  • Measure before and after, and check that stale data cannot hurt anyone.

Recap​

  • HybridCache.GetOrCreateAsync returns a stored answer, or runs the function once and stores it.
  • Decorators added caching to master data and the dashboard without changing their users.
  • Expiry times match how often data changes: one hour, five minutes, one minute.
  • Tags let AddSubjectAsync remove stale subject answers at once.
  • Repeated requests fell to about 2–4 ms; 139 tests pass.

Homework​

Library module task: Cache the library catalogue with HybridCache. A CatalogueStore stands in for the database: every read takes 200 ms and is counted. Cache GET /api/books for ten minutes with a tag, remove the tag when POST /api/books adds a book, and show the count at GET /api/stats.

A correct result looks like:

Output
GET books: 0.262s
GET books: 0.004s
GET books: 0.002s
GET /api/stats {"catalogueReads":1}
POST /api/books {"isbn":"9780143333623","title":"Swami and Friends"} {"message":"Swami and Friends added."}
GET /api/books (4 books, 0.214s)
GET /api/stats {"catalogueReads":2}
Show the solution
library-assignment/091-library-cache.cs
// Library homework, Lesson 91: cache the catalogue with HybridCache, and throw the cached copy away when a
// book is added. GET /api/stats shows how many times the "slow" catalogue store was really read.
// Run with: dotnet run library-assignment/091-library-cache.cs
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Extensions.Caching.Hybrid@10.10.0
#:property PublishAot=false
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Hybrid;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHybridCache();
builder.Services.AddSingleton<CatalogueStore>();
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5091");

public record Book(string Isbn, string Title);

// Stands in for the database: every read takes 200 ms and is counted.
public class CatalogueStore
{
private readonly List<Book> _books =
[
new Book("9780143039655", "Malgudi Days"),
new Book("9780141325293", "The Jungle Book"),
new Book("9788173711466", "Wings of Fire")
];

public int Reads { get; private set; }

public async Task<List<Book>> ReadAllAsync(CancellationToken cancellationToken)
{
Reads++;
await Task.Delay(200, cancellationToken);
return _books.OrderBy(b => b.Title).ToList();
}

public void Add(Book book) => _books.Add(book);
}

[ApiController]
[Route("api")]
public class LibraryController : ControllerBase
{
private const string Tag = "catalogue";
private readonly CatalogueStore _store;
private readonly HybridCache _cache;

public LibraryController(CatalogueStore store, HybridCache cache)
{
_store = store;
_cache = cache;
}

[HttpGet("books")]
public async Task<List<Book>> Books(CancellationToken cancellationToken) =>
await _cache.GetOrCreateAsync("books", async token => await _store.ReadAllAsync(token),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) }, [Tag], cancellationToken);

[HttpPost("books")]
public async Task<IActionResult> Add(Book book, CancellationToken cancellationToken)
{
_store.Add(book);
await _cache.RemoveByTagAsync(Tag, cancellationToken); // the cached list is out of date now
return Ok(new { message = $"{book.Title} added." });
}

[HttpGet("stats")]
public IActionResult Stats() => Ok(new { catalogueReads = _store.Reads });
}

Three requests read the store once. After the new book, the tag was removed, so the next request read it again.

Quiz​

  1. Which SMS data is a good fit for caching?

    1. A student's fee balance
    2. The current academic year
    3. Today's attendance being taken
    4. A user's password
    Show answer

    Answer: B. The current academic year Read on almost every request, changed once a year.

  2. What does HybridCache.GetOrCreateAsync do when the key is missing?

    1. Throws
    2. Runs the function once, stores the answer, and returns it
    3. Returns null
    4. Asks every server
    Show answer

    Answer: B. Runs the function once, stores the answer, and returns it With stampede protection, even for many requests at once.

  3. Why does the dashboard key include the date?

    1. For sorting
    2. The answer depends on the date; without it, one date's numbers would be returned for another
    3. Keys must be long
    4. The cache needs dates
    Show answer

    Answer: B. The answer depends on the date; without it, one date's numbers would be returned for another Every input of the answer belongs in the key.

  4. How did the new subject appear at once, although subjects are cached for an hour?

    1. The cache expired
    2. AddSubjectAsync removed every entry tagged subjects
    3. The API restarted
    4. Subjects are not cached
    Show answer

    Answer: B. AddSubjectAsync removed every entry tagged subjects The code that changes data removes the stale entries.

  5. Why did no controller or service change when caching was added?

    1. Caching is automatic
    2. The cached class is a decorator with the same interface, registered in place of the Dapper one
    3. Controllers cache by themselves
    4. Services were rewritten
    Show answer

    Answer: B. The cached class is a decorator with the same interface, registered in place of the Dapper one Only the DI registration changed.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.

Fresher (0-2 yrs)​

F What is a cache, and what data is worth caching? concept

A cache keeps a copy of an answer close by so the next request does not ask the source again. Cache data that is read far more often than it changes and that may safely be a little old, such as the current academic year or the list of subjects. Never cache data that must be exact, such as a fee balance.

See the example · Permalink

F What is the difference between [ResponseCache] and a server-side cache? concept

[ResponseCache] sends a Cache-Control header so each browser or proxy may keep its own copy; the server cannot take it back early. A server-side cache such as HybridCache keeps the copy in the API's memory, serves every client from it, and can remove entries when data changes. They work well together.

See the example · Permalink

F What makes a good cache key? concept

It names everything the answer depends on, and nothing else, so two different questions never share an answer. The SMS dashboard key is dashboard:{yearId}:{date}; leaving out the date would return one day's numbers for another. Per-user answers need the user in the key, or should not be cached.

See the example · Permalink

Mid (3-5 yrs)​

M How does HybridCache.GetOrCreateAsync behave, and what is stampede protection? concept

It returns the cached value for a key, or runs the given function once, stores its result, and returns it. Stampede protection means that when many requests ask for the same missing key at once, the function runs only once and all of them share the result, instead of all hitting the database together.

See the example · Permalink

M How do you add caching without changing the code that uses a repository? design

Write a decorator: a class that implements the same interface, holds the real repository, answers reads from the cache, and passes everything else through. Register it in place of the real one. In the SMS, CachedMasterDataRepository wraps the Dapper repository, and no controller or service changed.

See the example · Permalink

M How do you remove cached answers when the data changes? coding

Give related entries a tag when you store them, and remove the tag in the same code path that changes the data. In the SMS, every subject entry carries the tag "subjects", and AddSubjectAsync calls RemoveByTagAsync("subjects"), so the new subject appears at once instead of after an hour.

See the example · Permalink

Senior (6-9 yrs)​

S Parents see an old fee balance in the app after paying. What went wrong, and what do you change? scenario

The balance was cached, so the app showed the copy from before the payment. Money and other exact, per-person data should not be cached; if a cache is truly needed, remove the student's entry in the payment code path and keep expiry very short. Review every cached item with the question: what harm does the old version cause?

How to answer: Identify the stale cache as the cause, decide whether the data should be cached at all, and describe invalidation and review rules.

See the example · Permalink

S The API runs on three servers with in-memory caches. A subject is added on one of them. What happens? scenario

Only that server removes its cached subjects; the other two keep their copies until they expire, so users may see different lists for up to an hour. Options: shorter expiry, a shared distributed cache (such as Redis as HybridCache's second level), or broadcasting removals to every server.

How to answer: Explain per-server memory caches, the visible effect, and compare the fixes by freshness, cost, and complexity.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How would you design caching for an API that serves 1,000 schools? design

Put the school in every key (and tag by school), so no school ever sees another's data. Use a two-level cache: memory on each server for speed, plus a shared distributed cache for consistency and warm restarts. Cache master data and computed dashboards with expiry matched to how often each changes, remove tags in the code that changes data, protect against stampedes, and watch hit rates and memory per school.

How to answer: Cover isolation between tenants, the cache levels, what is cached and for how long, invalidation, and monitoring.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-91-caching (code repository coming soon)
  • Files: src/SchoolApp.Api/Caching/CachedMasterDataRepository.cs, src/SchoolApp.Api/Caching/CachedDashboardQueries.cs, src/SchoolApp.Api/Program.cs, tests/SchoolApp.Tests/Unit/CachedMasterDataTests.cs, library-assignment/091-library-cache.cs
  • Video: coming soon

What's next​

The API is faster under normal load. Next, it protects itself from abnormal load: rate limiting, starting with password guessing on the sign-in endpoint. Lesson 92: Rate limiting

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.