Worker Service — Standalone Background Processing

While hosted services in ASP.NET Core run in the same process as the web API, some background workloads benefit from a separate process: isolation (a background crash does not take down the API), independent scaling (run 10 worker instances vs 2 API instances), and independent deployment (deploy a new worker version without touching the API). The .NET Worker Service template creates a lightweight console application built on the Generic Host — it supports DI, configuration, logging, and hosted services, but without ASP.NET Core’s HTTP stack.

Worker Service Structure

// dotnet new worker -n BlogApp.Worker

// ── Program.cs — worker is a Generic Host without HTTP ───────────────────
var builder = Host.CreateApplicationBuilder(args);

// Same DI, configuration, and logging patterns as ASP.NET Core
builder.Services.AddHostedService<ImageProcessingWorker>();
builder.Services.AddScoped<IImageResizer, ImageResizer>();
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// Windows Service or Linux systemd support
builder.Services.AddWindowsService(opts => opts.ServiceName = "BlogApp Image Worker");
builder.Services.AddSystemd();  // for Linux systemd

var host = builder.Build();
await host.RunAsync();

// ── Worker.cs — the background service ───────────────────────────────────
public class ImageProcessingWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<ImageProcessingWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Image processing worker starting.");

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using var scope   = scopeFactory.CreateScope();
            var db            = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var resizer       = scope.ServiceProvider.GetRequiredService<IImageResizer>();

            var pending = await db.ImageJobs
                .Where(j => j.Status == JobStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var job in pending)
            {
                await resizer.ResizeAsync(job, stoppingToken);
                job.Status = JobStatus.Completed;
            }
            await db.SaveChangesAsync(stoppingToken);
        }
    }
}
Note: A Worker Service uses Host.CreateApplicationBuilder(args) instead of WebApplication.CreateBuilder(args). It does not register Kestrel, routing, or any HTTP middleware — just the Generic Host with DI, configuration, and logging. All the patterns from this chapter (BackgroundService, IServiceScopeFactory, PeriodicTimer, Channel<T>) work identically in a Worker Service as in an ASP.NET Core application. The only difference is there is no HTTP pipeline, so the worker is entirely background processing.
Tip: Use a shared class library project for types shared between the Web API and the Worker Service — the job model (ImageJob, EmailJob), the repository interfaces, and domain entities. Both projects reference the shared library. This avoids code duplication and ensures the API and worker use identical database schemas and business logic. The shared library should be a class library targeting the same framework version as both applications.
Warning: Worker Services running as Windows Services or systemd units need the corresponding NuGet package: Microsoft.Extensions.Hosting.WindowsServices for Windows Service support and Microsoft.Extensions.Hosting.Systemd for Linux systemd. Without AddWindowsService() or AddSystemd(), the host does not integrate with the service manager and will not start/stop correctly as a system service. Also ensure the UseContentRoot(AppContext.BaseDirectory) is set so the application can find its configuration files when run as a service (the working directory is not the application directory for system services).

Shared Queue Between API and Worker

// ── Database-backed queue: API writes, Worker reads ───────────────────────
// API project — controller enqueues work
[HttpPost("{id}/process-images")]
public async Task<IActionResult> ProcessImages(int id, CancellationToken ct)
{
    var post = await _repo.GetByIdAsync(id, ct);
    if (post is null) return NotFound();

    // Write job to database table — Worker polls this table
    var job = new ImageJob
    {
        PostId    = post.Id,
        ImagePath = post.CoverImagePath,
        Status    = JobStatus.Pending,
        CreatedAt = DateTime.UtcNow,
    };
    _db.ImageJobs.Add(job);
    await _db.SaveChangesAsync(ct);

    return Accepted(new { jobId = job.Id });
}

// Worker project — polls and processes
// Worker reads from ImageJobs where Status = Pending
// Updates Status = Processing, processes, updates Status = Completed/Failed
// This pattern is the "outbox pattern" / "transactional outbox"

Common Mistakes

Mistake 1 — Not setting UseContentRoot in Worker Service deployed as a system service

❌ Wrong — working directory is not the application directory; appsettings.json not found.

✅ Correct — set builder.UseContentRoot(AppContext.BaseDirectory) for system service deployments.

Mistake 2 — Putting CPU-intensive background work in the same process as the Web API

❌ Wrong — heavy CPU work in a hosted service competes with request handling threads, increasing API latency.

✅ Correct — move CPU-intensive background work (image processing, report generation, ML inference) to a separate Worker Service process that can scale and be resource-limited independently.

🧠 Test Yourself

What are the main advantages of running background processing in a separate Worker Service process rather than as a hosted service in the Web API?