Skip to content

Caching Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

Caching interview questions — IMemoryCache, IDistributedCache, Redis, cache-aside pattern, output caching, cache invalidation, and cache stampede.

Read the in-depth guideCaching in ASP.NET Core: IMemoryCache, Redis, and Output Cache(opens in new tab)
15 of 15

Caching stores the result of an expensive operation so subsequent requests can be served from fast memory instead of re-computing or re-querying. It reduces latency, cuts database load, and improves throughput under high concurrency.

// Without caching — every request hits the database:
public async Task<IEnumerable<Product>> GetProductsAsync()
    => await _db.Products.ToListAsync(); // ~50 ms per call

// With IMemoryCache — first call populates, subsequent calls < 1 ms:
public async Task<IEnumerable<Product>> GetProductsAsync()
{
    if (_cache.TryGetValue("products:all", out IEnumerable<Product>? cached))
        return cached!;

    var products = await _db.Products.ToListAsync();

    _cache.Set("products:all", products, TimeSpan.FromMinutes(5));
    return products;
}

The three canonical reasons to cache:

  1. Expensive queries — database joins, aggregations, full-text search
  2. External API calls — rate-limited or slow third-party services
  3. Computed results — serialization, report generation, complex calculations

Rule of thumb: Cache data that is read far more often than it changes and where a slightly stale value is acceptable. Never cache security-sensitive data (session tokens, permissions) without careful thought about invalidation.

IMemoryCache is an in-process, per-server cache backed by ConcurrentDictionary. It is registered by default in ASP.NET Core and injected like any service.

// Registration (already included in most default templates):
builder.Services.AddMemoryCache();

// Injection and usage:
public class ProductService
{
    private readonly IMemoryCache   _cache;
    private readonly AppDbContext   _db;

    public ProductService(IMemoryCache cache, AppDbContext db)
    {
        _cache = cache;
        _db    = db;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        var key = $"product:{id}";

        // GetOrCreateAsync — atomic "get or populate" in one call:
        return await _cache.GetOrCreateAsync(key, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SlidingExpiration               = TimeSpan.FromMinutes(2);
            entry.Priority                        = CacheItemPriority.Normal;

            return await _db.Products.FindAsync(id);
        });
    }

    public void Invalidate(int id) => _cache.Remove($"product:{id}");
}

Key options:

  • AbsoluteExpirationRelativeToNow — evict after a fixed duration from insertion
  • SlidingExpiration — evict if not accessed for this duration (reset on each access)
  • Priority — controls eviction order under memory pressure (Low evicted first)

Rule of thumb: IMemoryCache is ideal for single-server deployments and data that fits in process memory. For multi-server deployments use IDistributedCache (Redis) so all instances share the same cache.

IDistributedCache is an abstraction for a shared, out-of-process cache. Multiple application instances read and write the same cache store, so there is no inconsistency between servers. Redis and SQL Server are the most common implementations.

// Registration — Redis:
builder.Services.AddStackExchangeRedisCache(opts =>
{
    opts.Configuration = builder.Configuration.GetConnectionString("Redis");
    opts.InstanceName  = "myapp:";
});

// Or in-memory (for dev/test, single process only):
builder.Services.AddDistributedMemoryCache();

// Usage — IDistributedCache works with byte[] or string:
public class ProductService
{
    private readonly IDistributedCache _cache;

    public async Task<Product?> GetByIdAsync(int id)
    {
        var key  = $"product:{id}";
        var json = await _cache.GetStringAsync(key);

        if (json is not null)
            return JsonSerializer.Deserialize<Product>(json);

        var product = await _db.Products.FindAsync(id);
        if (product is null) return null;

        await _cache.SetStringAsync(key,
            JsonSerializer.Serialize(product),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
                SlidingExpiration               = TimeSpan.FromMinutes(2),
            });

        return product;
    }

    public async Task InvalidateAsync(int id)
        => await _cache.RemoveAsync($"product:{id}");
}
IMemoryCache IDistributedCache
Scope Single process All instances
Speed < 1 ms ~1–5 ms (network)
Capacity Limited by RAM Redis cluster capacity
Consistency Per-server Shared

Rule of thumb: Use IMemoryCache on single-server deployments or for transient per-request data. Use IDistributedCache (Redis) whenever you run more than one application instance.

Cache-aside (lazy loading) means the application manages the cache explicitly: check the cache first, fetch from the source on a miss, write back to the cache, then return. The cache is a side structure that the application populates on demand.

// Generic cache-aside helper — eliminates repeated boilerplate:
public static class CacheExtensions
{
    public static async Task<T?> GetOrSetAsync<T>(
        this IDistributedCache cache,
        string key,
        Func<Task<T?>> factory,
        TimeSpan expiry)
    {
        var json = await cache.GetStringAsync(key);
        if (json is not null)
            return JsonSerializer.Deserialize<T>(json);

        var value = await factory();     // 1. MISS — call the source
        if (value is not null)
            await cache.SetStringAsync(  // 2. POPULATE the cache
                key,
                JsonSerializer.Serialize(value),
                new DistributedCacheEntryOptions
                    { AbsoluteExpirationRelativeToNow = expiry });

        return value;                    // 3. RETURN to caller
    }
}

// Call site is now one line:
var product = await _cache.GetOrSetAsync(
    $"product:{id}",
    () => _db.Products.FindAsync(id).AsTask(),
    TimeSpan.FromMinutes(10));

// On write — invalidate the cache entry so the next read is fresh:
await _db.SaveChangesAsync();
await _cache.RemoveAsync($"product:{id}");

The alternative is write-through: update the cache at the same time as the source. Write-through keeps the cache warm but adds write latency and complexity when the write fails.

Rule of thumb: Use cache-aside when reads are much more frequent than writes and a brief window of stale data is acceptable. Use write-through for write-heavy workloads where staleness is not tolerable.

Response caching caches the full HTTP response at the middleware level, keyed by URL and Vary headers. It avoids running the controller and service layer entirely on cache hits.

// Program.cs:
builder.Services.AddResponseCaching();
// ...
app.UseResponseCaching(); // must come before routing / endpoints

// Controller or action — [ResponseCache] sets the Cache-Control header:
[HttpGet("products")]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "page", "size" })]
public async Task<IActionResult> GetProducts(int page = 1, int size = 20)
{
    var products = await _productService.GetPageAsync(page, size);
    return Ok(products);
}

// Equivalent header set by the above:
// Cache-Control: public, max-age=60
// Vary: page, size

// Profile — reuse settings across multiple actions:
builder.Services.AddControllersWithViews(opts =>
{
    opts.CacheProfiles.Add("5MinutePublic", new CacheProfile
    {
        Duration = 300,
        Location = ResponseCacheLocation.Any,
    });
});

[ResponseCache(CacheProfileName = "5MinutePublic")]
public IActionResult StaticData() => Ok(_staticData);

Response caching only works for GET/HEAD requests, non-authenticated responses, and when no Authorization header is present. It relies on the client or a proxy honouring Cache-Control.

Rule of thumb: Use response caching for public, read-only endpoints that return the same data for all users. For per-user or authenticated responses, use IMemoryCache or IDistributedCache at the service layer instead.

Output caching (introduced in .NET 7) caches the rendered endpoint output on the server. Unlike response caching, it is server-controlled and works regardless of client Cache-Control headers or Authorization headers.

// Program.cs:
builder.Services.AddOutputCache(opts =>
{
    // Named policy — reuse across multiple endpoints:
    opts.AddPolicy("5min", policy => policy.Expire(TimeSpan.FromMinutes(5)));

    // Base policy — applies to all endpoints unless overridden:
    opts.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(60)));
});

app.UseOutputCache(); // after UseRouting, before endpoints

// Apply to a minimal API endpoint:
app.MapGet("/products", async (AppDbContext db) => await db.Products.ToListAsync())
   .CacheOutput("5min");

// Apply to a controller action:
[HttpGet]
[OutputCache(PolicyName = "5min")]
public async Task<IActionResult> GetProducts()
    => Ok(await _service.GetAllAsync());

// Vary by query string parameter:
opts.AddPolicy("ByPage", policy =>
    policy.SetVaryByQuery("page", "size").Expire(TimeSpan.FromMinutes(5)));

// Tag-based invalidation — evict cache entries by tag:
[HttpPost]
public async Task<IActionResult> UpdateProduct(Product p)
{
    await _service.UpdateAsync(p);
    await _outputCacheStore.EvictByTagAsync("products", ct); // invalidate
    return NoContent();
}

Key difference from response caching: output caching caches on the server and can cache authenticated responses and vary on any request property (headers, route values, custom values). Response caching is HTTP cache semantics delegated to the client or proxy.

Rule of thumb: Prefer output caching in .NET 7+ — it gives you server-side control, tag-based invalidation, and works with authenticated endpoints. Use response caching only when you need standard HTTP cache semantics for CDN or proxy caching.

Cache invalidation is famously hard because stale data causes correctness bugs while over-invalidation erases all performance benefit.

// Strategy 1: TTL (Time-To-Live) expiry — simplest, always stale briefly
_cache.Set("products:all", products,
    new MemoryCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
    });

// Strategy 2: Explicit removal on write — fresh immediately after mutation
public async Task UpdateProductAsync(Product product)
{
    await _db.SaveChangesAsync();
    _cache.Remove($"product:{product.Id}");   // evict specific entry
    _cache.Remove("products:all");              // evict collection
}

// Strategy 3: Cache tags (output cache) — evict by semantic group
app.MapPut("/products/{id}", async (int id, Product p, IOutputCacheStore store, CancellationToken ct) =>
{
    await _db.SaveChangesAsync();
    await store.EvictByTagAsync("products", ct); // evicts all "products"-tagged entries
});
app.MapGet("/products", GetAll).CacheOutput(p => p.Tag("products"));
app.MapGet("/products/{id}", GetOne).CacheOutput(p => p.Tag("products"));

// Strategy 4: CancellationTokenSource — invalidate a group via token
private CancellationTokenSource _productsCts = new();

_cache.Set("products:all", data, new MemoryCacheEntryOptions()
    .AddExpirationToken(new CancellationChangeToken(_productsCts.Token)));

public void InvalidateAll()
{
    _productsCts.Cancel();
    _productsCts = new CancellationTokenSource(); // reset for next group
}

Rule of thumb: Use TTL for data that can tolerate brief staleness (catalogs, reference data). Use explicit removal for data where staleness causes bugs (user profiles, inventory counts). Use tag-based eviction with output cache for groups of related entries.

StackExchange.Redis is the standard Redis client for .NET. Beyond IDistributedCache, it supports pub/sub, sorted sets, Lua scripting, and distributed locks — patterns that go well beyond simple key/value caching.

// Install:
// dotnet add package StackExchange.Redis
// dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

// Program.cs — register both the cache and the raw connection:
builder.Services.AddStackExchangeRedisCache(opts =>
    opts.Configuration = "localhost:6379,password=secret,abortConnect=false");

// Direct access via IConnectionMultiplexer (for pub/sub, sets, etc.):
builder.Services.AddSingleton<IConnectionMultiplexer>(
    ConnectionMultiplexer.Connect("localhost:6379"));

// Distributed lock (prevents cache stampede):
public class InventoryService
{
    private readonly IConnectionMultiplexer _redis;

    public async Task<int> GetStockAsync(string sku)
    {
        var db  = _redis.GetDatabase();
        var key = $"stock:{sku}";

        // Try to acquire a lock so only one thread recomputes on a miss:
        var lockKey   = $"lock:{key}";
        var lockValue = Guid.NewGuid().ToString();
        var acquired  = await db.StringSetAsync(lockKey, lockValue,
            TimeSpan.FromSeconds(5), When.NotExists);

        if (!acquired)
        {
            await Task.Delay(50);        // wait and retry
            return await GetStockAsync(sku);
        }

        try
        {
            var cached = await db.StringGetAsync(key);
            if (cached.HasValue) return (int)cached;

            var stock = await _db.Inventory.Where(i => i.Sku == sku).SumAsync(i => i.Qty);
            await db.StringSetAsync(key, stock, TimeSpan.FromMinutes(1));
            return stock;
        }
        finally { await db.KeyDeleteAsync(lockKey); }
    }
}

Redis use cases beyond caching: rate limiting (sliding window counters), session storage, real-time leaderboards (sorted sets), pub/sub message fan-out, and distributed work queues.

Rule of thumb: Use IDistributedCache for standard caching and configuration- swappable behavior. Use IConnectionMultiplexer directly only when you need Redis-specific data structures or pub/sub.

A cache stampede (also called "thundering herd") occurs when a popular cache entry expires and many concurrent requests all miss at the same time, all racing to rebuild it from the slow data source simultaneously.

// Problem — naive cache-aside under load:
// 1000 concurrent requests all miss → 1000 DB queries fire simultaneously
if (!_cache.TryGetValue(key, out var value))
{
    value = await _db.Products.ToListAsync(); // all 1000 threads hit here!
    _cache.Set(key, value, TimeSpan.FromMinutes(5));
}

// Solution 1: SemaphoreSlim — only one thread rebuilds, others wait:
private static readonly SemaphoreSlim _lock = new(1, 1);

public async Task<IEnumerable<Product>> GetProductsAsync()
{
    if (_cache.TryGetValue("products", out IEnumerable<Product>? cached))
        return cached!;

    await _lock.WaitAsync();
    try
    {
        // Double-check after acquiring — another thread may have populated it:
        if (_cache.TryGetValue("products", out cached))
            return cached!;

        cached = await _db.Products.ToListAsync();
        _cache.Set("products", cached, TimeSpan.FromMinutes(5));
        return cached;
    }
    finally { _lock.Release(); }
}

// Solution 2: Lazy<Task<T>> per key — collapses concurrent misses into one op:
private readonly ConcurrentDictionary<string, Lazy<Task<IEnumerable<Product>>>> _locks = new();

public Task<IEnumerable<Product>> GetProductsAsync()
    => _locks.GetOrAdd("products", _ => new Lazy<Task<IEnumerable<Product>>>(
        () => _db.Products.ToListAsync())).Value;

// Solution 3: Probabilistic early refresh (background repopulation before expiry):
entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
{
    EvictionCallback = (k, v, r, s) =>
    {
        if (r == EvictionReason.Expired)
            _ = Task.Run(() => RefreshCacheAsync(k.ToString()!));
    }
});

Rule of thumb: Use SemaphoreSlim with a double-check pattern for high-traffic endpoints. Background refresh is the most sophisticated approach but adds complexity — reach for it only when measured latency spikes justify it.

By default, IMemoryCache has no size limit and can grow unbounded. You can set a size limit and assign a size cost to each entry to cap memory usage.

// Registration with size limit (units are arbitrary — just needs to be consistent):
builder.Services.AddMemoryCache(opts =>
{
    opts.SizeLimit = 1024; // max 1024 "units"
});

// Each entry declares its size cost:
_cache.Set("small-lookup", value, new MemoryCacheEntryOptions
{
    Size     = 1,                                          // costs 1 unit
    Priority = CacheItemPriority.High,                     // last to be evicted
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
});

_cache.Set("large-dataset", bigList, new MemoryCacheEntryOptions
{
    Size     = 50,                                         // costs 50 units
    Priority = CacheItemPriority.Low,                      // evicted first under pressure
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
});

// Compact — manually free a percentage of cache:
if (_cache is MemoryCache mc)
    mc.Compact(0.25); // evict 25% of items by priority/size/expiry

// Monitor cache stats:
var stats = mc.GetCurrentStatistics();
Console.WriteLine($"Entries: {stats?.CurrentEntryCount}, " +
                  $"Size: {stats?.CurrentEstimatedSize}");

Without SizeLimit, setting Size on entries has no effect. Both must be configured together.

Rule of thumb: Set SizeLimit whenever cached entries vary significantly in size (e.g., large JSON blobs vs small integers). Without it, a burst of large entries can exhaust process memory silently.

ASP.NET Core session stores per-user state across requests using a session cookie. The session data is backed by IDistributedCache, so Redis or SQL Server can be used as the session store.

// Program.cs:
builder.Services.AddDistributedMemoryCache(); // or AddStackExchangeRedisCache
builder.Services.AddSession(opts =>
{
    opts.IdleTimeout        = TimeSpan.FromMinutes(20); // inactivity expiry
    opts.Cookie.HttpOnly    = true;
    opts.Cookie.IsEssential = true;   // GDPR: always set the cookie
    opts.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

app.UseSession(); // after UseRouting, before endpoints

// Reading and writing session values:
app.MapGet("/cart/count", (HttpContext ctx) =>
{
    var count = ctx.Session.GetInt32("CartCount") ?? 0;
    return Results.Ok(count);
});

app.MapPost("/cart/add", (HttpContext ctx) =>
{
    var count = (ctx.Session.GetInt32("CartCount") ?? 0) + 1;
    ctx.Session.SetInt32("CartCount", count);
    return Results.Ok(count);
});

// Complex objects — serialize to JSON manually:
ctx.Session.SetString("Cart", JsonSerializer.Serialize(cartItems));
var cart = JsonSerializer.Deserialize<List<CartItem>>(
    ctx.Session.GetString("Cart") ?? "[]");

Session is backed by IDistributedCache — switching from in-memory to Redis changes only the AddDistributedMemoryCache() call, not the session code.

Rule of thumb: Use session for lightweight per-user transient state (cart item count, wizard step). Store larger or durable user data in a database keyed by user ID, not in session.

HybridCache (introduced in .NET 9, available as a NuGet package for .NET 8) combines a fast in-process L1 cache (IMemoryCache) with a shared L2 cache (IDistributedCache / Redis). A cache hit serves from L1 in < 1 ms; on L1 miss it checks L2 before falling back to the data source.

// Installation:
// dotnet add package Microsoft.Extensions.Caching.Hybrid

// Registration:
builder.Services.AddHybridCache(opts =>
{
    opts.MaximumPayloadBytes         = 1024 * 1024; // 1 MB max per entry
    opts.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration         = TimeSpan.FromMinutes(5),
        LocalCacheExpiration = TimeSpan.FromMinutes(1), // L1 evicts sooner
    };
});

// Usage — GetOrCreateAsync handles L1 hit, L2 hit, and source fetch:
public class ProductService
{
    private readonly HybridCache _cache;

    public async ValueTask<Product?> GetByIdAsync(int id, CancellationToken ct)
        => await _cache.GetOrCreateAsync(
            $"product:{id}",
            async cancel => await _db.Products.FindAsync(new object[] { id }, cancel),
            cancellationToken: ct);

    public async Task InvalidateAsync(int id)
        => await _cache.RemoveAsync($"product:{id}");
}

Key benefits over manual L1+L2 wiring:

  • Stampede protection built in — only one call to the factory per missing key
  • Serialization handled — no manual JsonSerializer.Serialize per call
  • Tag-based invalidationRemoveByTagAsync("products") evicts from both levels

Rule of thumb: In .NET 9+, prefer HybridCache over manual IMemoryCache + IDistributedCache wiring. It solves stampede, serialization, and L1/L2 sync in one abstraction.

Measuring cache effectiveness requires both micro-benchmarks (to measure the cache hit path) and load tests (to measure real-world throughput improvements).

// BenchmarkDotNet micro-benchmark — compare cached vs uncached:
// dotnet add package BenchmarkDotNet
[MemoryDiagnoser]
public class ProductBenchmarks
{
    private readonly IMemoryCache  _cache = new MemoryCache(new MemoryCacheOptions());
    private readonly FakeDbContext _db    = new();

    [GlobalSetup]
    public void Setup() => _cache.Set("products", _db.Products.ToList());

    [Benchmark(Baseline = true)]
    public List<Product> NoCache()     => _db.Products.ToList();

    [Benchmark]
    public List<Product>? WithCache()  =>
        _cache.TryGetValue("products", out List<Product>? p) ? p : null;
}

// Key cache metrics to track in production:
// - Hit rate (hits / total requests): target > 90% for high-value entries
// - Miss latency: how long does a cache miss take vs a cache hit?
// - Eviction rate: high evictions → size limit too small or TTL too short

// Expose hit/miss counters via metrics (System.Diagnostics.Metrics):
private static readonly Counter<long> _hits   = Metrics.Meter.CreateCounter<long>("cache.hits");
private static readonly Counter<long> _misses = Metrics.Meter.CreateCounter<long>("cache.misses");

public async Task<Product?> GetByIdAsync(int id)
{
    if (_cache.TryGetValue($"product:{id}", out Product? p))
    {
        _hits.Add(1, new TagList { { "cache", "product" } });
        return p;
    }
    _misses.Add(1, new TagList { { "cache", "product" } });
    // ... fetch and populate
    return p;
}

Rule of thumb: Measure before and after. A cache that is never hit (low hit rate) wastes memory. A cache that is invalidated too aggressively offers no benefit. Track hit rate per cache key in production dashboards.

Cache warming (also called pre-population) loads frequently accessed data into the cache at application startup, before the first real request arrives. This avoids a burst of slow cache misses immediately after a deployment.

// IHostedService that runs once at startup to populate the cache:
public class CacheWarmupService : IHostedService
{
    private readonly IMemoryCache  _cache;
    private readonly AppDbContext  _db;
    private readonly ILogger<CacheWarmupService> _logger;

    public CacheWarmupService(
        IMemoryCache cache,
        AppDbContext db,
        ILogger<CacheWarmupService> logger)
    {
        _cache  = cache;
        _db     = db;
        _logger = logger;
    }

    public async Task StartAsync(CancellationToken ct)
    {
        _logger.LogInformation("Warming caches...");

        // Load reference data that every request needs:
        var categories = await _db.Categories
            .AsNoTracking()
            .ToListAsync(ct);

        _cache.Set("categories:all", categories, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
            Priority = CacheItemPriority.NeverRemove, // do not evict under pressure
        });

        var topProducts = await _db.Products
            .OrderByDescending(p => p.Views)
            .Take(100)
            .AsNoTracking()
            .ToListAsync(ct);

        foreach (var product in topProducts)
            _cache.Set($"product:{product.Id}", product, TimeSpan.FromMinutes(30));

        _logger.LogInformation("Cache warm-up complete: {Count} products loaded",
            topProducts.Count);
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

// Registration — runs before the app starts accepting traffic:
builder.Services.AddHostedService<CacheWarmupService>();

In Kubernetes, keep the readiness probe returning 503 until warm-up completes by setting a flag that the readiness health check reads:

// Simple flag — readiness check returns Unhealthy until flag is set:
builder.Services.AddSingleton<CacheWarmupFlag>();
builder.Services.AddHealthChecks()
    .AddCheck<CacheWarmupHealthCheck>("cache-warmup", tags: ["readiness"]);

Rule of thumb: Warm only the data that is both expensive to compute and accessed on virtually every request. Warming too much delays startup and wastes memory on data that may never be needed.

An ETag is a hash or version token attached to an HTTP response. On subsequent requests the client sends the ETag back in If-None-Match; if the resource has not changed the server returns 304 Not Modified with no body, saving bandwidth and processing.

// Manual ETag — compute a hash of the response data:
[HttpGet("products/{id}")]
public async Task<IActionResult> GetProduct(int id)
{
    var product = await _cache.GetOrCreateAsync($"product:{id}",
        entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
                   return _db.Products.FindAsync(id).AsTask(); });

    if (product is null) return NotFound();

    // Compute a deterministic ETag from the entity's updated timestamp:
    var etag = new EntityTagHeaderValue(
        $"\"{product.UpdatedAt.Ticks}\"");

    // Check the request's If-None-Match header:
    if (Request.Headers.IfNoneMatch.ToString() == etag.ToString())
        return StatusCode(StatusCodes.Status304NotModified); // no body sent

    Response.Headers.ETag = etag.ToString();
    Response.Headers.CacheControl = "private, max-age=0, must-revalidate";
    return Ok(product);
}

// Middleware approach — ResponseCaching honours ETags automatically
// when the response has Cache-Control: public, max-age=N
// and the client sends If-None-Match or If-Modified-Since.

// For collections — hash the whole result set:
var hash = Convert.ToHexString(
    SHA256.HashData(
        Encoding.UTF8.GetBytes(JsonSerializer.Serialize(products))));
var etag = new EntityTagHeaderValue($"\"{hash}\"");

ETag benefits:

  • Bandwidth savings — 304 response has no body
  • Client freshness — client always gets fresh data when it changes
  • Reduced server load — skip serialization on 304 path

Rule of thumb: Use ETags for large resources (product listings, reports) where the response body is kilobytes or more. For tiny payloads the overhead of hashing and the extra round-trip makes ETags counterproductive — a short max-age is simpler and faster.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel