← Back to all posts

Fire-and-Forget Email in ASP.NET Core: a BackgroundService Queue + MailAnvil

2026-10-15 · MailAnvil Team

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:

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