Lesson 29 of 45 advanced

Background Jobs, Performance and Scale

Take slow work out of the request, measure before tuning, and scale out safely

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

A BackgroundService needs a scoped DbContext for each job. What is the right approach?

Open interactive version (quiz + challenge)

Real-world analogy

At a restaurant counter, the cashier takes your order and gives you a number. The cashier does not cook, because cooking would block the line behind you. The order goes onto a ticket rail, and a cook picks tickets one by one in the kitchen. The kitchen needs its own set of tools for each ticket, and the cashier's till is not available to it. Here the cashier is the web request, the ticket rail is a queue, the cook is a background worker, and the tools are a fresh scope per job.

What is it?

Background processing means doing work outside the HTTP request, so the user gets a fast response. In ASP.NET Core you can run a BackgroundService that reads jobs from a Channel, or use a library such as Hangfire or Quartz when jobs must survive restarts, run on a schedule or be retried. Performance work is a loop of measuring, finding the real bottleneck and fixing it. Scaling out is possible when the app keeps no per-user state in its own memory.

Real-world relevance

A checkout page that sends a receipt email inside the request is slow, and it fails if the mail server is down. Moving the email to a queue makes the page fast and lets the worker retry. When a report endpoint is slow, teams that profile often find one N+1 query rather than a CPU problem. And an app that stores sessions in server memory often breaks when a second server is added, because the next request may reach a server that does not know the user.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Why might you choose Hangfire over a Channel with a BackgroundService?

An endpoint is slow. What should you do first?

Code example

public record EmailJob(int OrderId);
public class EmailQueue
{
    private readonly Channel<EmailJob> _channel = Channel.CreateBounded<EmailJob>(100);

    public ValueTask EnqueueAsync(EmailJob job, CancellationToken ct) =>
        _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<EmailJob> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

public class EmailWorker(EmailQueue queue, IServiceScopeFactory scopes, ILogger<EmailWorker> log)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var job in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                using var scope = scopes.CreateScope();            // fresh scope per job
                var sender = scope.ServiceProvider.GetRequiredService<IReceiptSender>();
                await sender.SendAsync(job.OrderId, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                log.LogError(ex, "Email job for order {OrderId} failed", job.OrderId);
            }
        }
    }
}
// Program.cs
builder.Services.AddSingleton<EmailQueue>();
builder.Services.AddHostedService<EmailWorker>();

Line-by-line walkthrough

  1. 1. EmailJob is a small record that holds only the order id, not the whole order.
  2. 2. EmailQueue wraps a bounded Channel of 100 jobs, so a flood of requests cannot fill memory.
  3. 3. EnqueueAsync writes a job to the channel, and ReadAllAsync gives the worker a stream of jobs.
  4. 4. EmailWorker inherits BackgroundService, and ExecuteAsync loops with await foreach until the app stops.
  5. 5. For each job it creates a scope and resolves IReceiptSender from it, so scoped services live and die with that job. IReceiptSender is a scoped service, not shown, that uses the DbContext and sends the email.
  6. 6. The catch block logs a failed job and lets the loop continue, but it lets cancellation through.
  7. 7. In Program.cs, the last two lines register the queue as a singleton, so the controller and the worker share one channel, and add the worker as a hosted service.

Spot the bug

[HttpPost]
public IActionResult Create(CreateReport req)
{
    _ = Task.Run(async () =>
    {
        var rows = await db.Orders.Where(o => o.CreatedAt >= req.From).ToListAsync();   // db is the request's scoped DbContext
        await reports.SaveAsync(rows);
    });
    return Accepted();
}
Need a hint?
When does the request's scope end, and what is the task still using?
Show answer
The fire-and-forget task keeps using the request's scoped DbContext after the response has been sent. The scope is then disposed, so the task can fail with an ObjectDisposedException, and its errors are never observed. Fix: put the job on a queue such as a Channel and let a BackgroundService read it and create its own scope, or use a persistent job library like Hangfire.

Explain like I'm 5

When you order food, the cashier gives you a ticket and the cook makes it in the back, so the line keeps moving. The cook needs their own tools for every ticket. And if the cook gets the same ticket twice, the plate should still come out right.

Fun fact

A bounded Channel makes a fast producer wait when the queue is full instead of eating all memory. This behaviour is called back-pressure, and it is why bounded queues are usually safer than unbounded ones.

Hands-on challenge

Say it out loud in 30 seconds: "A page is slow because it sends an email and builds a report. How do you fix it and keep it reliable?" Then check: did you mention moving the work to a queue and a background worker, a scope per job, idempotent jobs with retries, and measuring first?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook