Background jobs and caching
How to run work outside an HTTP request and how to cache data and responses: hosted services and Worker Service, Hangfire and Quartz.NET, graceful shutdown and background failures, in-memory and distributed cache, output cache and HybridCache, cache stampede and invalidation. In interviews this topic tests whether you understand the guarantees: what survives a restart, what runs twice and what goes stale.
Before this topic: Async, Dependency injection.
What are IHostedService and BackgroundService, and how do you register them?
Section titled “What are IHostedService and BackgroundService, and how do you register them?”What it is
IHostedService— a service with two methods: the host callsStartAsyncwhen the app starts andStopAsyncwhen it stops.BackgroundService— a base class on top of it. You only overrideExecuteAsync(CancellationToken stoppingToken), the method with the background loop.
Why
To do work outside HTTP requests: read a queue, sync data on a timer, warm up a cache.
How to use
- Derive from
BackgroundServiceand run a loop untilstoppingTokenis cancelled. - Register the service:
builder.Services.AddHostedService<Worker>(). - For a process without HTTP, use the
dotnet new workertemplate. Such a worker runs as a Windows service (AddWindowsService()) or under systemd (AddSystemd()).
Common mistake
Not passing stoppingToken to Task.Delay and other calls. Then app shutdown waits for the timeout.
Takeaway: BackgroundService plus AddHostedService is the standard way to run background work.
How it works
AddHostedService<T>()registers the service as a singletonIHostedService.- On startup the host calls
StartAsyncon every service, one at a time, in registration order. In ASP.NET Core this happens before the server starts. BackgroundService.StartAsyncstartsExecuteAsyncand returns immediately. The task lives untilstoppingTokenis cancelled.- On shutdown the host calls
StopAsyncin reverse order.BackgroundServicecancels the token and waits forExecuteAsync, but no longer than the shutdown timeout.
When to choose what
| Task | Choice |
|---|---|
| Endless loop: queue, timer | BackgroundService |
| Short initialization before serving requests | IHostedService with work in StartAsync |
| Background process without HTTP | Worker Service: Host.CreateApplicationBuilder |
Pitfalls
- A hosted service is a singleton, so you cannot inject
DbContextor other scoped services into its constructor. Create a scope per iteration withIServiceScopeFactory.CreateAsyncScope()— see Dependency injection for details. - For periodic work use
PeriodicTimer(.NET 6+). UnlikeSystem.Threading.Timer, its iterations never overlap. - Every app instance runs its own copy of the worker. There is no coordination between them.
Rule of thumb
Long loop — in ExecuteAsync, short initialization — in StartAsync, a new scope — per unit of work.
Takeaway: a hosted service lives as long as the host and is driven by it through StartAsync, StopAsync and the stopping token.
Under the hood
AddHostedServiceregisters the service viaTryAddEnumerable: registering the same type twice does not create a second instance.- Before .NET 10, the synchronous part of
ExecuteAsyncup to the firstawaitran insideStartAsyncand delayed the start of other services and the server. Since .NET 10BackgroundServiceruns the whole method viaTask.Run. AddWindowsService()andAddSystemd()do not touch service code; they replaceIHostLifetime. A Windows service also getsAppContext.BaseDirectoryas its content root, because its current directory isSystem32.
Cost
- No persistence: in-memory work is lost on deploy, restart or crash.
- When you scale out, every instance does the same work: two pods, two nightly reports.
- Background work shares the thread pool and CPU with requests.
Trade-offs
| Option | Upside | Downside |
|---|---|---|
| Worker in the web process | one deployment | a web restart kills jobs |
| Separate Worker Service | own releases and scaling | one more process |
A single executor across the whole farm needs a lock in shared storage or a scheduler with clustering.
How to check in production
Log the start and end of each iteration and expose the time of the last iteration in a health check. A stuck worker shows up as a timestamp that stopped moving.
Takeaway: a hosted service is a simple in-process mechanism; it gives no delivery guarantees, no scheduling and no single-executor guarantee across a farm.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHostedService<HeartbeatWorker>(); // singleton, starts with the host
var app = builder.Build();app.MapGet("/", () => "OK");app.Run();
public sealed class HeartbeatWorker(ILogger<HeartbeatWorker> logger) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Ticks don't overlap even if an iteration takes longer than the period using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); while (await timer.WaitForNextTickAsync(stoppingToken)) // cancelled on shutdown { logger.LogInformation("Heartbeat at {Time}", DateTimeOffset.Now); } }}- How does
BackgroundServicediffer fromIHostedService? — It is a base class: it startsExecuteAsyncitself, and inStopAsyncit cancels the token and waits for the task. - Can you inject
DbContextinto a worker’s constructor? — No, the worker is a singleton; create a scope viaIServiceScopeFactoryper iteration. - When does
StartAsyncrun relative to Kestrel? — Before the server starts, sequentially for all services. - How do you run a worker as a Windows service? — The
Microsoft.Extensions.Hosting.WindowsServicespackage,AddWindowsService()and registration withsc.exe create. - What happens with two app instances? — Two workers do the same job; remove duplicates with a lock or a clustered scheduler.
How does IMemoryCache differ from IDistributedCache, and what are absolute and sliding expiration?
Section titled “How does IMemoryCache differ from IDistributedCache, and what are absolute and sliding expiration?”What it is
IMemoryCache |
IDistributedCache |
|
|---|---|---|
| Where it stores | process memory | Redis, SQL Server, Postgres |
| What it stores | any object | byte[] and strings |
| After a restart | empty | data is still there |
How to use
- Register the cache:
AddMemoryCache()or, for example,AddStackExchangeRedisCache(...)from theMicrosoft.Extensions.Caching.StackExchangeRedispackage. - Inject the interface and read through
GetOrCreateAsync, orGetStringAsyncandSetStringAsync. - Set a lifetime:
- absolute (
AbsoluteExpirationRelativeToNow) — the entry lives no longer than N minutes; - sliding (
SlidingExpiration) — the entry is removed if nobody read it for N minutes, and every read extends it.
- absolute (
Common mistake
Setting only sliding expiration. A popular entry is read all the time and serves stale data for ages.
Takeaway: memory is fast, but every server has its own copy; a distributed cache is shared, but goes over the network.
How it works
MemoryCacheis a thread-safe in-process dictionary. Values are stored by reference: change an object after reading it and you change it for everyone.IDistributedCacheonly knowsbyte[]; serialization is up to you.Refreshextends the sliding window without reading the value.AddDistributedMemoryCache()is an in-process implementation ofIDistributedCache. It is for development and tests and is not actually distributed.
When to choose what
| Situation | Cache |
|---|---|
| Single instance, reference data, settings | IMemoryCache |
| Farm, data must match across nodes | IDistributedCache (Redis) |
| Need both memory speed and a shared cache | L1 in memory on top of L2 in Redis |
Pitfalls
IMemoryCacheis not trimmed under memory pressure. Bound it withSizeLimiton a dedicatedMemoryCacheinstance: with a limit, everyone who writes to the cache must specify a size.- Mutating a cached object changes it for every reader. Cache immutable data.
- Keys built from user input bloat the cache — a DoS vector.
Rule of thumb
The code must work without the cache: a cache is an optimization, not a data store.
Takeaway: IMemoryCache is for data that need not be consistent across nodes, IDistributedCache is for data the whole farm shares.
Under the hood
- Expiration is lazy. An expired entry is never returned on read; it is removed during cache accesses and background scans no more often than
ExpirationScanFrequency(once a minute by default). - With
SizeLimitset, an entry withoutSizethrowsInvalidOperationException. When the cache overflows it compacts itself: expired entries first, then by priority and recency of use. IDistributedCachestores bytes together with expiration metadata. A sliding window is extended on read, which means a write to the store on every hit.
Cost
- L1 multiplies memory by the number of nodes. Large long-lived objects settle in generation 2 and the LOH.
- L2 costs a network call and serialization per hit. A Redis outage must not take the app down: short timeouts and a fallback to the source.
Trade-offs
- L1 on different nodes diverges for the lifetime of an entry. A short L1 lifetime or invalidation messages narrow that window.
- A two-level scheme gives memory speed plus a shared L2, but makes invalidation harder.
How to check in production
MemoryCacheOptions.TrackStatistics = trueandGetCurrentStatistics()(.NET 7+) report hits, misses and entry count.- Memory growth shows up in
dotnet-countersas heap size, and what the cache actually holds shows up indotnet-gcdump.
Takeaway: a local cache is speed without consistency, a distributed cache is consistency at the cost of the network; size and lifetimes are always the developer’s job.
using Microsoft.Extensions.Caching.Distributed;using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddMemoryCache();builder.Services.AddDistributedMemoryCache(); // in production: AddStackExchangeRedisCache(...)
var app = builder.Build();
app.MapGet("/rates", async (IMemoryCache cache) => await cache.GetOrCreateAsync("rates", entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); // no longer than 10 minutes entry.SlidingExpiration = TimeSpan.FromMinutes(2); // or 2 minutes without reads return Task.FromResult(new[] { 41.5m, 44.9m }); // the object is stored as is }));
app.MapGet("/users/{id:int}/name", async (int id, IDistributedCache cache) =>{ var key = $"user-name:{id}"; var name = await cache.GetStringAsync(key); // byte[] under the hood if (name is null) { name = $"User {id}"; // "load from the database" await cache.SetStringAsync(key, name, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }); } return name;});
app.Run();- What does
IMemoryCachedo under memory pressure? — Nothing: it does not trim itself; bound it withSizeLimitand expirations. - Why is
AddDistributedMemoryCachenot distributed? — The data lives in process memory; it is a stand-in for development and single-server setups. - Why combine sliding and absolute expiration? — Otherwise a popular entry never expires.
- What goes into Redis: an object or JSON? — Only bytes; serialization format, schema version and size are on you.
- How does caching relate to load balancing? — Without a shared cache nodes answer differently; you need a distributed cache or sticky sessions.
When do you reach for Hangfire or Quartz.NET instead of BackgroundService?
Section titled “When do you reach for Hangfire or Quartz.NET instead of BackgroundService?”What it is
- Hangfire runs ordinary methods in the background:
BackgroundJob.Enqueue(() => SendEmail(id)). The job is saved to the database, executed, retried on failure, and everything is visible in a web dashboard. - Quartz.NET is a scheduler: a job (
IJob) plus a trigger that decides when to run it, for example on a cron schedule.
Why
BackgroundService lives in process memory. Restart the app and you lose queued work and miss scheduled runs.
How to use
| Need | Tool |
|---|---|
| Loop, polling, timer | BackgroundService |
| Send an email in the background with retries | Hangfire |
| Nightly report at 02:00 | Hangfire RecurringJob or Quartz |
Common mistake
Passing an EF entity or a large object to a Hangfire job. Arguments are serialized to JSON and stored in the database, so pass an id.
Takeaway: if you need reliability or a schedule, use a scheduler; for a simple loop BackgroundService is enough.
How it works
- Hangfire. The client serializes the method call — type, method, arguments — into storage. The Hangfire server (a hosted service with a pool of workers) picks jobs from the queue. The
AutomaticRetryfilter retries a failed job 10 times by default with growing delays. - Quartz.NET. Jobs and triggers live in a
JobStore:RAMJobStorein memory by default,AdoJobStorein a database for durability. Missed runs, for example while the app was down, are handled by the trigger’s misfire policy.
When to choose what
| Criterion | Hangfire | Quartz.NET |
|---|---|---|
| Model | queue of method calls | jobs and triggers |
| Storage | always persistent | in memory or AdoJobStore |
| Retries | AutomaticRetry out of the box |
manual |
| Scheduling | cron | cron with seconds, calendars |
| Multiple nodes | shared storage | clustered AdoJobStore |
| Dashboard | built in | Quartz.Dashboard package |
Pitfalls
- Hangfire stores type and method names: renaming a method or changing its signature breaks jobs already sitting in the queue.
- After a worker crash a job runs again, so make it idempotent.
- The Hangfire dashboard only allows local requests by default; production needs your own authorization filter.
Rule of thumb
Persistence, retries or “one executor per farm” — an external scheduler. Everything else — BackgroundService.
Takeaway: Hangfire is a reliable queue of calls with retries, Quartz is precise scheduling with clustering.
Under the hood
- Hangfire with SQL Server fetches a job so that other servers cannot see it. If the server dies mid-job, the job goes back to the queue after a timeout and runs again — hence the at-least-once guarantee.
- Quartz cluster nodes compete for triggers through row locks in the database and check in every 7.5 seconds. A node whose clock runs fast can declare a healthy peer dead, so clocks must be synchronized.
Cost
- Constant load on the database plus its own table schema with migrations.
- Job arguments are a contract: their serialized form outlives the version of the code that produced it.
- Running twice is normal, not an incident. Build idempotency into every job.
Trade-offs
- Redis storage and batches in Hangfire come with the paid Pro edition.
- Quartz 4.0 (September 2026) runs only on .NET 10 and changed the API:
IJob.ExecutereturnsValueTaskand takes aCancellationToken, and the database schema needs a migration. The 3.x line is still maintained for .NET Framework. - Passing jobs between services — a message broker. Periodically running a separate process — a Kubernetes CronJob.
How to check in production
Watch the number of jobs in the Failed and Retrying states and queue lengths in the dashboard. In Quartz, alert on the message is still active but was recovered by another instance — a sign of clock skew in the cluster.
Takeaway: a scheduler gives persistence and retries at the cost of database load, versioned arguments and mandatory idempotency.
// NuGet: Hangfire.AspNetCore, Hangfire.SqlServerusing Hangfire;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHangfire(c => c.UseSqlServerStorage( builder.Configuration.GetConnectionString("Hangfire")));builder.Services.AddHangfireServer(); // the job processor is a hosted service toobuilder.Services.AddScoped<ReceiptService>();
var app = builder.Build();app.MapHangfireDashboard("/jobs"); // local requests only by default
// The call is serialized to the database; up to 10 retries on failureapp.MapPost("/orders/{id:int}/receipt", (int id, IBackgroundJobClient jobs) => Results.Accepted(value: jobs.Enqueue<ReceiptService>(s => s.SendAsync(id))));
// Every day at 02:00 (UTC by default)app.Services.GetRequiredService<IRecurringJobManager>() .AddOrUpdate<ReceiptService>("cleanup", s => s.CleanupAsync(), Cron.Daily(2));
app.Run();
public class ReceiptService{ public Task SendAsync(int orderId) => Task.CompletedTask; // an id, not an entity public Task CleanupAsync() => Task.CompletedTask;}// NuGet: Quartzusing Quartz;
var builder = Host.CreateApplicationBuilder(args);builder.Services.AddQuartz(q =>{ // Quartz cron starts with seconds: every day at 02:00:00 q.ScheduleJob<NightlyReportJob>(t => t.WithIdentity("nightly-report").WithCronSchedule("0 0 2 * * ?")); q.UsePersistentStore(s => { s.UseSqlServer(builder.Configuration.GetConnectionString("Quartz")!); s.UseClustering(); // the trigger fires on one node s.UseSystemTextJsonSerializer(); });});builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);builder.Build().Run();
[DisallowConcurrentExecution] // one execution per job key across the clusterpublic sealed class NightlyReportJob : IJob{ // In 3.x the signature differs: Task Execute(IJobExecutionContext context) public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default;}- What happens if a Hangfire server crashes mid-job? — After a timeout the job returns to the queue and runs again; hence idempotency.
- Why pass an id to a job instead of an entity? — Arguments are serialized to the database: the object goes stale and bloats storage.
- How does Quartz avoid running a job twice in a cluster? —
AdoJobStorewith database locks plus[DisallowConcurrentExecution]per job key. - What is a misfire? — A missed trigger fire; the policy decides whether to run it now, once, or skip it.
- When do you need neither Hangfire nor Quartz? — For a simple in-process loop, and for messaging between services, where a broker fits.
Response caching, output caching and HybridCache: which one when?
Section titled “Response caching, output caching and HybridCache: which one when?”What it is
| Mechanism | What it caches | Where |
|---|---|---|
| Response caching | HTTP response | browser, proxy, CDN, middleware |
| Output caching (.NET 7+) | HTTP response | server: memory or Redis |
HybridCache (.NET 9+) |
data in code | memory plus a distributed cache |
How to use
- Response caching:
[ResponseCache(Duration = 60)]setsCache-Control: public,max-age=60. Server-side caching comes fromAddResponseCaching()andUseResponseCaching(). - Output caching:
AddOutputCache(),UseOutputCache()and.CacheOutput()on an endpoint. HybridCache: theMicrosoft.Extensions.Caching.Hybridpackage,AddHybridCache()andGetOrCreateAsync(key, factory).
Common mistake
Caching responses for authenticated users: one user gets another’s data. Output cache does not cache such responses by default — do not turn that rule off without varying the key by user.
Takeaway: headers are for clients and CDNs, output cache is for responses on the server, HybridCache is for data.
How it works
- Response caching middleware follows RFC 9111: it caches GET and HEAD with status 200, without
AuthorizationandSet-Cookie, and honours the request’sCache-Control. A browser sendingmax-age=0bypasses the cache. - Output caching ignores client headers. By default it caches only 200 responses to GET and HEAD, without cookies and without authentication, for 60 seconds. The key is the whole URL, tuned with
SetVaryByQueryandSetVaryByHeader. HybridCache:GetOrCreateAsyncchecks L1 (MemoryCache), then L2 (the registeredIDistributedCache), and only then calls the factory — once per key per app instance. Values are serialized with System.Text.Json.
When to choose what
| Task | Choice |
|---|---|
| Static content and public GETs for a CDN | Cache-Control, [ResponseCache] |
| Whole pages and API responses | output cache |
| Results of database and external API calls | HybridCache |
Pitfalls
- Put
UseOutputCacheafterUseCors,UseAuthenticationandUseAuthorization, otherwise you may serve a response cached for another user. - For output cache on several nodes use
AddStackExchangeRedisOutputCache(.NET 8+), notIDistributedCache: tags need atomicity. - By default
HybridCachedeserializes a new copy on every read. Instance reuse is enabled bysealedplus[ImmutableObject(true)]on the type.
Rule of thumb
First decide what you cache — a response or data — then where the copy lives and who clears it.
Takeaway: the server controls output cache and HybridCache; the client controls response caching.
Under the hood
- Output cache keeps responses in
IOutputCacheStore: in memory with a 100 MB limit by default, while the Redis store supports tags atomically. Revalidation viaETagandIf-Modified-Sincereturns 304 without a body. HybridCachecoalesces concurrent calls for a key within an instance. The factory’s token is cancelled only when every waiting caller has cancelled.HybridCachetags are invalidation timestamps: on read, an entry older than the timestamp counts as a miss.
Cost
HybridCache: serialization on every L2 write and read. Entries larger thanMaximumPayloadBytes(1 MB by default) are not cached.- Output cache without Redis keeps a separate copy on every node.
SetVaryByQuery("*")multiplies the number of entries by the number of parameter combinations.
Trade-offs
- Freshness versus load: a CDN
max-agecannot be recalled. A common setup is a shortmax-agefor clients plus output cache with tag eviction on the server. HybridCachehas no built-in way to broadcast invalidations between nodes (a backplane). FusionCache, which implements the same abstraction, provides one.
How to check in production
Look at the Age and Cache-Control headers in responses, count factory calls and database queries, and compare P95 latency before and after enabling the cache.
Takeaway: pick the layer by who must be able to clear the copy: a CDN — nobody, output cache — the server by tag, HybridCache — code by key or tag.
using Microsoft.AspNetCore.OutputCaching;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddOutputCache(o => o.AddPolicy("products", p => p.Expire(TimeSpan.FromMinutes(5)).Tag("products")));
var app = builder.Build();app.UseOutputCache();
// The whole response is stored on the server; the client's Cache-Control has no effectapp.MapGet("/products", () => new[] { "apple", "pear" }).CacheOutput("products");
app.MapPost("/products", async (IOutputCacheStore store, CancellationToken ct) =>{ await store.EvictByTagAsync("products", ct); // drop every response with the tag return Results.NoContent();});
// Response caching: just a header for the browser and CDNapp.MapGet("/logo", (HttpContext ctx) =>{ ctx.Response.Headers.CacheControl = "public,max-age=86400"; return "logo";});
app.Run();using Microsoft.Extensions.Caching.Hybrid;
// Registration: builder.Services.AddHybridCache() from the Microsoft.Extensions.Caching.Hybrid package.// L2 is the registered IDistributedCache, for example Redis.public sealed class PriceService(HybridCache cache){ private static readonly HybridCacheEntryOptions Options = new() { Expiration = TimeSpan.FromMinutes(10), // overall lifetime, including L2 LocalCacheExpiration = TimeSpan.FromMinutes(1) // shorter L1: other nodes won't hear about a reset };
// Concurrent misses for a key in this process wait for a single load public ValueTask<decimal> GetAsync(int id, CancellationToken ct) => cache.GetOrCreateAsync($"price:{id}", async token => { await Task.Delay(100, token); // "database query" return id * 10m; }, Options, tags: ["prices"], cancellationToken: ct);
// Logical reset: entries older than this moment count as misses public ValueTask InvalidateAllAsync(CancellationToken ct) => cache.RemoveByTagAsync("prices", ct);}- Why does the browser bypass response caching? — It sends
Cache-Control: max-age=0, and the middleware must honour it. - How does a tag in output cache differ from a tag in
HybridCache? — The first evicts entries from the store, the second logically marks them stale. - How does output cache protect against a rush of requests on a miss? — Resource locking: one request runs the handler, the rest wait.
- Can output cache be stored in
IDistributedCache? — Not recommended: tags need atomicity; useAddStackExchangeRedisOutputCache(.NET 8+). - Do you need .NET 9 for
HybridCache? — No: the abstraction appeared in .NET 9, but the package even runs on .NET Framework 4.7.2.
How do you stop background work gracefully, and what happens on an unhandled exception?
Section titled “How do you stop background work gracefully, and what happens on an unhandled exception?”What it is
Graceful shutdown means a background task finishes its current work and exits on its own instead of being cut off mid-operation.
How it works
- The app receives a stop signal: Ctrl+C, a service stop,
docker stop. - The host cancels the
stoppingTokenthatExecuteAsyncreceived. - The code notices the cancellation — through an
awaitwith that token or anIsCancellationRequestedcheck — and exits. - The host waits for it, but no longer than the timeout: 30 seconds by default.
What about exceptions
If ExecuteAsync throws, the host logs the exception and stops the app. So the error of a single iteration is caught inside the loop, logged, and the loop continues.
Common mistake
while (true) and Task.Delay without the token. The app hangs until the timeout, and the work is cut off.
Takeaway: pass stoppingToken to every call and catch per-iteration errors inside the loop.
How it works
- A stop signal raises
ApplicationStopping, and the host callsStopAsyncon services in reverse registration order. BackgroundService.StopAsynccancels the token and waits forExecuteAsyncor for the stop token to fire, whichever comes first. That token fires afterShutdownTimeout.- When time runs out, the host carries on shutting down, and unfinished work dies with the process.
- The host logs an exception from
ExecuteAsyncasBackgroundService failed. AnOperationCanceledExceptionduring shutdown is not treated as an error.
When to choose what
| Situation | Choice |
|---|---|
| An iteration may fail, the service must keep running | try/catch inside the loop |
| The app is pointless without the service | let the exception stop the host |
| Shutdown needs more time | HostOptions.ShutdownTimeout |
Pitfalls
BackgroundServiceExceptionBehavior.Ignoreturns a failed service into a zombie: the process is alive, but no work happens.StartAsyncruns sequentially and before the server starts, so heavy work there delays startup.- A timer that starts a new iteration without waiting for the previous one leaves a tail of unfinished operations on shutdown.
Rule of thumb
Every await in background work gets stoppingToken, per-iteration errors are caught in the loop, and a failure of the whole service is a signal to stop the host.
Takeaway: the host asks for a stop with a token and waits for a limited time; whatever has not finished gets cut off.
Under the hood
- The host awaits each
BackgroundServicetask separately. On failure it logs and, withStopHost, callsStopApplication(). - In .NET 11 (due in November 2026),
RunAsyncandStopAsyncthrow the service’s exception after such a stop, and the exit code becomes non-zero. Before that the app exited with code 0. IHostedLifecycleService(.NET 8) addsStartingAsync,StartedAsync,StoppingAsyncandStoppedAsync. The stopping methods count against the sameShutdownTimeout.
Cost
ShutdownTimeoutis one budget for everyone: Kestrel drains requests, and services stop one after another unlessServicesStopConcurrentlyis on.- The orchestrator’s window is stricter: Kubernetes gives 30 seconds before SIGKILL,
docker stop10, systemd 90.
Trade-offs
- A
Channel<T>queue loses items on shutdown and on crash. The options are to drain it within the budget or keep work outside the process (Hangfire, a broker, an outbox). - A bounded channel gives backpressure: when it is full,
WriteAsyncwaits, and requests slow down instead of memory growing. - For a Windows service the docs recommend
Environment.Exit(1)after a fatal error so that service recovery kicks in. The price:finallyblocks and graceful shutdown do not run.
How to check in production
Search the logs for BackgroundService failed and measure the time from Application is shutting down to exit. On a staging environment, send SIGTERM under load and check that in-flight work completes.
Takeaway: graceful shutdown means a token in every call, a budget smaller than the orchestrator’s window, and work that survives being cut off.
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);builder.Services.Configure<HostOptions>(o =>{ o.ShutdownTimeout = TimeSpan.FromSeconds(20); // below the orchestrator's window (K8s: 30 s) o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost; // the default});builder.Services.AddSingleton(Channel.CreateBounded<string>(100)); // when full, WriteAsync waitsbuilder.Services.AddHostedService<EmailSender>();
var app = builder.Build();app.MapPost("/emails", async (string to, Channel<string> queue, CancellationToken ct) =>{ await queue.Writer.WriteAsync(to, ct); return Results.Accepted();});app.Run();
public sealed class EmailSender(Channel<string> queue, ILogger<EmailSender> logger) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // On shutdown reading stops, and unread addresses are lost await foreach (var to in queue.Reader.ReadAllAsync(stoppingToken)) { try { await Task.Delay(200, CancellationToken.None); // finish the email we started } catch (Exception ex) { logger.LogError(ex, "Failed to send email to {To}", to); // one failure doesn't bring down the host } } }}- How long does the host wait for background services by default? — 30 seconds since .NET 6, 5 before; set it with
HostOptions.ShutdownTimeout. - Why doesn’t a Windows service restart after the worker fails? —
StopHoststops the host cleanly with exit code 0; you needEnvironment.Exit(1)or .NET 11. - What happens to items in a
Channel<T>on shutdown? — Unprocessed ones are lost; drain within the budget or keep the queue outside the process. - Why
IHostedLifecycleService? — Code before and afterStartAsyncandStopAsyncof all services: migrations before startup, draining before shutdown. - Will a console app without a host handle SIGTERM on .NET 10? — No: the runtime no longer installs a handler; the host installs one through
ConsoleLifetime.
What is a cache stampede, and how do you handle cache invalidation and consistency?
Section titled “What is a cache stampede, and how do you handle cache invalidation and consistency?”What it is
A cache stampede is a mass miss: an entry expired, and every request goes to the database at once. The database gets hundreds of times its usual load and may fall over.
How to protect against it
- One request per key, the others wait for its result. That is how
HybridCache.GetOrCreateAsyncand output cache work. - Different lifetimes: add random seconds to the expiration so keys do not expire at the same moment.
How to update data
Changed data in the database — delete the key from the cache (RemoveAsync). The next read loads the fresh version.
Common mistake
Assuming IMemoryCache.GetOrCreateAsync calls the factory once. With concurrent misses several requests will call it.
Takeaway: on a miss one request goes to the source, and after a write the key is deleted.
How it works
- Cache-aside: read the cache; on a miss load from the source and store with a lifetime.
- Write: commit to the database, then delete the key. Deletion is idempotent and never stores a value that lost a race.
- Request coalescing (single-flight): a task for the in-flight load is kept per key, and other misses await it.
When to choose what
| Pattern | When |
|---|---|
| Cache-aside | the default: the cache is optional, load lazily |
| Write-through | writes go to cache and database synchronously; reads right after writes are common |
| Write-behind | write to cache, flush to the database later in batches; some risk of loss is acceptable |
Pitfalls
- Data loaded at startup with the same lifetime expires all at once. Add jitter.
- Deleting the key before the transaction commits: a concurrent read puts the old value back.
- Non-existent keys hit the database every time. Cache “not found” too, with a short lifetime.
Rule of thumb
Always a lifetime, delete the key after commit, coalesce requests for expensive keys.
Takeaway: cache-aside with delete-after-write and jittered lifetimes solves most freshness and load problems.
Under the hood
HybridCachecoalesces calls only within an instance: five nodes mean up to five loads. Output cache resource locking is local too.- A distributed lock (for example
SET NX PXin Redis) brings loads down to one but needs a timeout: the holder may die, and the lock may expire mid-work. - Refreshing early (refresh-ahead) and serving the stale value while reloading (stale-while-revalidate) take misses off the hot path. In FusionCache these are eager refresh and fail-safe.
Cost
Waiting callers share the latency of one load. If it fails, they all get the error — or, with fail-safe, the stale value.
Trade-offs
- The race: request A misses and reads the old value from the database, B writes the new value and deletes the key, A stores the old value in the cache. The lifetime bounds this window; short lifetimes, versioned keys (
product:42:v7) and a delayed second delete narrow it. - L1 on other nodes is cleared with an event (Redis pub/sub, a bus, CDC or an outbox) or a short
LocalCacheExpiration. - Groups of entries are cleared with tags:
RemoveByTagAsyncinHybridCache,EvictByTagAsyncin output cache.
How to check in production
Track the hit ratio and the number of factory calls per key. Database latency spikes that line up with lifetime boundaries are a sign of synchronized expiration. Run load tests with a cold cache too — after a deploy or a Redis flush.
Takeaway: a stampede is tamed by request coalescing and jittered lifetimes, and consistency is bounded by lifetimes, delete-after-commit and invalidation events.
using System.Collections.Concurrent;using Microsoft.Extensions.Caching.Memory;
var cache = new MemoryCache(new MemoryCacheOptions());var inFlight = new ConcurrentDictionary<int, Lazy<Task<decimal>>>();int dbCalls = 0;
async Task<decimal> GetPriceAsync(int id){ if (cache.TryGetValue(id, out decimal price)) return price; // hit // Single-flight: every miss for the key awaits one load var load = inFlight.GetOrAdd(id, key => new Lazy<Task<decimal>>(async () => { Interlocked.Increment(ref dbCalls); await Task.Delay(100); // "database query" var ttl = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(Random.Shared.Next(60)); // jitter return cache.Set(key, key * 10m, ttl); })); try { return await load.Value; } finally { inFlight.TryRemove(KeyValuePair.Create(id, load)); } // remove only our own load}
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => GetPriceAsync(42)));Console.WriteLine(dbCalls); // 1- Does
IMemoryCache.GetOrCreateAsyncprotect against a stampede? — No: with concurrent misses the factory runs several times. - Why delete the key on write instead of updating it? — Deletion is idempotent and never stores a value that lost a race.
- What does lifetime jitter give you? — Keys loaded together expire at different times, so the load does not arrive as a spike.
- How many loads does
HybridCachedo on five nodes? — Up to five: coalescing works within an instance. - How do you clear L1 on every node? — With an event over pub/sub or a bus, or with a short L1 lifetime.