Skip to main content

Newsletter Archive

Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.

Page 4 of 15 (43 editions)

May 10, 2026

PeriodicTimer: The Async-Friendly Way to Schedule Recurring Work

If you’ve written a BackgroundService with while + Task.Delay, you’ve probably introduced drift without realizing it.

The Problem

while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(); // takes 30 seconds
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // then waits 5 more
}
// Actual interval: 5:30, not 5:00. Drifts further over time.

The delay starts after the work finishes, so your interval is always work time + delay time.

The Fix

using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await DoWorkAsync();
}

PeriodicTimer ticks at a fixed interval regardless of how long the work takes. If the work runs past a tick, the next WaitForNextTickAsync returns immediately (one tick is buffered), then resumes the normal cadence. No drift, no overlap, no queued-up flood of missed ticks.

In a BackgroundService

public class PricePollingService(
IHttpClientFactory httpFactory,
ILogger<PricePollingService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
var client = httpFactory.CreateClient("pricing");
var price = await client.GetFromJsonAsync<decimal>("/api/price", stoppingToken);
logger.LogInformation("Current price: {Price:C}", price);
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "Fetch failed. Will retry next tick.");
}
}
}
}

Disposing the timer causes WaitForNextTickAsync to return false, so the loop exits cleanly. Cancellation via stoppingToken works the same way.

Task.Delay vs PeriodicTimer

BehaviorTask.Delay loopPeriodicTimer
Interval includes work time?Yes (drift)No (fixed)
Overlap possible?Yes, if not carefulNo
Missed ticks pile up?N/ANo, one buffered
Disposal exits the loop?NoYes

Key Takeaway

PeriodicTimer is a one-line swap that gives your background services fixed-interval ticks, no drift, and clean shutdown. If you have a while + Task.Delay loop, replace it.

Read the full tip + 9 curated links

Don't miss the next tip 💧

Get a .NET tip and curated links delivered to your inbox every week.

May 3, 2026

Keyed Services: Resolving the Right Implementation Without Factory Hacks

You have two implementations of the same interface. Before .NET 8, you either registered a Func<string, IService> factory, built a custom resolver, or stuffed a switch into a single implementation. None of it felt right.

.NET 8 added keyed services to the built-in DI container.

The Problem

Two notification senders, one interface. Registering both means the last one wins:

builder.Services.AddSingleton<INotificationSender, EmailSender>();
builder.Services.AddSingleton<INotificationSender, SmsSender>(); // this one wins

The Fix

Register each with a key:

builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>("sms");

Inject the one you want with [FromKeyedServices]:

app.MapPost("/notify/email", async (
[FromKeyedServices("email")] INotificationSender sender,
NotificationRequest request) =>
{
await sender.SendAsync(request.To, request.Message);
return Results.Ok();
});

It works in constructor injection too:

public class OrderProcessor(
[FromKeyedServices("email")] INotificationSender emailSender,
[FromKeyedServices("sms")] INotificationSender smsSender)
{
// Both are available, resolved by key
}

Dynamic Resolution

When you don’t know the key at compile time, resolve through IServiceProvider:

app.MapPost("/notify/{channel}", async (string channel, IServiceProvider sp) =>
{
var sender = sp.GetKeyedService<INotificationSender>(channel);
if (sender is null) return Results.BadRequest($"Unknown channel: {channel}");
await sender.SendAsync("[email protected]", "Hello!");
return Results.Ok();
});

Keys can be any object, not just strings. Enums work great:

builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>(Channel.Email);
builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>(Channel.Sms);

Key Takeaway

Keyed services replace hand-rolled factories with a first-class DI concept. Register by key, inject with [FromKeyedServices], and stop writing Func<string, IService> workarounds.

Read the full tip + 9 curated links

Don't miss the next tip 💧

Get a .NET tip and curated links delivered to your inbox every week.

April 26, 2026

HybridCache: One API for In-Memory + Distributed Caching

Caching in ASP.NET Core has always meant choosing between IMemoryCache (fast, single-node) and IDistributedCache (shared, slower). In practice you want both: a fast L1 in-memory layer backed by shared L2 storage like Redis. But wiring them together manually means duplicate code, stampede bugs, and serialization headaches.

HybridCache in .NET 9 wraps both layers behind a single call.

Setup

Terminal window
dotnet add package Microsoft.Extensions.Caching.Hybrid
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // L2 (distributed)
LocalCacheExpiration = TimeSpan.FromMinutes(2) // L1 (memory)
};
});
// Optional: add a distributed backend
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

Without an IDistributedCache registration, HybridCache still works as an in-memory cache with stampede protection.

Basic Usage

app.MapGet("/products/{id:int}", async (int id, HybridCache cache) =>
{
var product = await cache.GetOrCreateAsync(
$"product:{id}",
async cancel => await LoadProductFromDb(id, cancel));
return product is not null ? Results.Ok(product) : Results.NotFound();
});

First call: factory runs, result is stored in L1 and L2. Subsequent calls within the local expiration window come straight from memory without touching Redis.

Stampede Protection

This is the headline feature. If 100 concurrent requests all miss the cache for the same key, only one runs the factory. The other 99 wait for that single result. No configuration needed. It’s the default behavior.

Tag-Based Invalidation

Group related entries with tags and invalidate them in one call:

// When caching, attach a tag
var products = await cache.GetOrCreateAsync(
$"products:category:{category}",
async cancel => await LoadByCategory(category, cancel),
tags: ["products"]);
// When data changes, evict everything tagged "products"
await cache.RemoveByTagAsync("products");

For single entries, RemoveAsync clears from both L1 and L2:

await cache.RemoveAsync($"product:{id}");

Key Takeaway

HybridCache gives you L1 + L2 caching, stampede protection, and tag-based invalidation behind a single GetOrCreateAsync call. Stop hand-rolling two-layer cache logic.

Read the full tip + 9 curated links

Don't miss the next tip 💧

Get a .NET tip and curated links delivered to your inbox every week.