Server-Side Caching with HybridCache – Web API | Lesson 91
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.GetOrCreateAsyncwith 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
| Data | Changes | Cache for | Why |
|---|---|---|---|
| Current academic year | once a year | 1 hour | read by almost every request |
| Subjects | rarely, by the admin | 1 hour, removed when one is added | every screen shows them |
| Classes with student counts | with every enrollment | 5 minutes | counts may be a little old |
| Dashboard for a date | constantly | 1 minute | many people open it; a minute is fine |
| A student's marks or fees | any time | not cached | must 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 lives | in each browser or proxy | in the API's memory |
| Who benefits | one client, asking again | every client, including the first one each hour |
| Can the API remove it early | no: it is on the client | yes: by key or by tag |
| Good for | public answers such as subjects | anything 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
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
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
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:
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
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
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
- 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.
- 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.
- Forgetting to remove stale entries. A new subject is missing for an hour. Fix: tags, removed by the code that makes the change.
- Caching per-user answers in a shared key. One user sees another's data. Fix: include the user, or do not cache it.
- 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
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.GetOrCreateAsyncreturns 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
AddSubjectAsyncremove 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:
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 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
Which SMS data is a good fit for caching?
Show answer
Answer: B. The current academic year Read on almost every request, changed once a year.
What does HybridCache.GetOrCreateAsync do when the key is missing?
Show answer
Answer: B. Runs the function once, stores the answer, and returns it With stampede protection, even for many requests at once.
Why does the dashboard key include the date?
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.
How did the new subject appear at once, although subjects are cached for an hour?
Show answer
Answer: B. AddSubjectAsync removed every entry tagged subjects The code that changes data removes the stale entries.
Why did no controller or service change when caching was added?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.