Fire-and-Forget Email in ASP.NET Core: a BackgroundService Queue + MailAnvil
Fire-and-Forget Email in ASP.NET Core: a BackgroundService Queue + MailAnvil
Here's the bug we see constantly in .NET apps: the checkout endpoint sends the order-confirmation email inline, the email API is slow for two seconds, and now every checkout takes two seconds too — or the whole request fails because a third-party email API hiccuped. Email is not the user's job. It's a background job.
This tutorial builds a tiny in-process email queue for .NET 8 using System.Threading.Channels — no Hangfire, no Redis, no external queue service. ~90 lines. If you're sending more than a few thousand emails per hour or need durability across restarts, upgrade to a real queue (we'll say when); until then, this covers the 90%.
1. The queue
Channel<T> is stdlib. A bounded channel gives you backpressure: when the queue is full, writes await instead of ballooning memory.
public record EmailJob(string To, string Subject, string Html, int Attempt = 0);
public interface IEmailQueue
{
ValueTask EnqueueAsync(EmailJob job, CancellationToken ct = default);
IAsyncEnumerable<EmailJob> DequeueAllAsync(CancellationToken ct);
}
public class ChannelEmailQueue : IEmailQueue
{
private readonly Channel<EmailJob> _channel =
Channel.CreateBounded<EmailJob>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
public async ValueTask EnqueueAsync(EmailJob job, CancellationToken ct = default)
=> await _channel.Writer.WriteAsync(job, ct);
public async IAsyncEnumerable<EmailJob> DequeueAllAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var job in _channel.Reader.ReadAllAsync(ct))
yield return job;
}
}
Register as singleton — one shared queue per app:
builder.Services.AddSingleton<IEmailQueue, ChannelEmailQueue>();
builder.Services.AddHostedService<EmailSenderService>();
2. The sender
BackgroundService runs for the app's lifetime. Reads from the queue, POSTs to MailAnvil, retries with backoff on 429/5xx:
public class EmailSenderService(
IEmailQueue queue, IHttpClientFactory httpFactory,
IConfiguration cfg, ILogger<EmailSenderService> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var http = httpFactory.CreateClient("mailanvil"); // see part 1 for setup
await foreach (var job in queue.DequeueAllAsync(stoppingToken))
{
try
{
await SendAsync(http, job, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
if (job.Attempt < 3)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, job.Attempt + 1)); // 2s, 4s, 8s
log.LogWarning(ex, "Send to {To} failed, retry {Attempt}", job.To, job.Attempt + 1);
await Task.Delay(delay, stoppingToken);
await queue.EnqueueAsync(job with { Attempt = job.Attempt + 1 }, stoppingToken);
}
else
{
log.LogError(ex, "Send to {To} dropped after 3 attempts", job.To);
// persist to a dead-letter store here in production
}
}
}
}
private static async Task SendAsync(HttpClient http, EmailJob job, CancellationToken ct)
{
using var res = await http.PostAsJsonAsync("/send", new
{
from = cfg_EmailFrom(),
to = new[] { job.To },
subject = job.Subject,
html = job.Html,
}, ct);
res.EnsureSuccessStatusCode();
}
private static string cfg_EmailFrom() =>
// static access kept out of this snippet for brevity; inject IConfiguration instead
Environment.GetEnvironmentVariable("MAILANVIL_FROM")!;
}
ponytail: the cfg_EmailFrom static helper is a snippet shortcut — in the real repo inject IConfiguration and read MailAnvil:From like part 1 showed.
Wait — Task.Delay inside the read loop blocks the whole queue during backoff. For low volume that's fine (emails arrive slower than retries resolve). At higher throughput, fan out: consume into a Parallel.ForEachAsync with MaxDegreeOfParallelism = 5.
3. Fire-and-forget from an endpoint
The endpoint does zero email work:
app.MapPost("/checkout", async (CheckoutRequest req, IEmailQueue emails) =>
{
// ... save order to DB first, always
await emails.EnqueueAsync(new EmailJob(
To: req.Email,
Subject: $"Order #{req.OrderId} confirmed",
Html: $"<p>Thanks! Order <b>{req.OrderId}</b> is on its way.</p>"));
return Results.Accepted();
});
Return Results.Accepted() — the order is stored; delivery is asynchronous by design.
4. Honest limits of this design
This queue is in-memory. Know what that means:
- App restarts drop queued emails. If an email must arrive (OTP, invoice), enqueue after a durable state change and reconcile on startup, or use Hangfire/Quartz.NET with persistent storage.
- No visibility across instances. Multiple pods each get their own queue — fine for transactional email, wrong if you need global ordering.
- The 429 path still burns a retry attempt locally; the API's
Retry-Afterheader is the smarter signal — parse it and delay by that value instead of guessing.
If any of those matter, graduate to Hangfire (SQL storage, free) — the EmailJob record ports directly.
5. Verify before you trust it
One runnable check — a fake handler that counts calls and asserts a retry happened:
[Fact]
public async Task RetriesOn5xx()
{
var calls = 0;
var http = new HttpClient(new FakeHandler(() => ++calls == 1
? new(HttpStatusCode.InternalServerError)
: new(HttpStatusCode.OK) { Content = new StringContent("""{"id":"1"}""") }));
// enqueue one job, run sender with short delays, assert calls == 2
}
Ship it, then check your MailAnvil dashboard: you should see message IDs appear seconds after checkout, with your HTTP request latency untouched.
Full API reference: docs.mailanvil.com