.NET Programming With Me

Background Jobs in .NET 8 Web API with IHostedService and Hangfire

Some work has no business running inside an HTTP request. Sending a welcome email, resizing an upload, generating a report, or cleaning up stale records all make the caller wait for something they do not need to wait for. Worse, if the process restarts mid-request, that work is lost. Background jobs solve both problems.

By the end of this post you will know when to reach for the built-in IHostedService versus Hangfire, and you will have working examples of both: a recurring cleanup task with a hosted service, and a durable, retryable fire-and-forget job with Hangfire backed by PostgreSQL.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and an existing ASP.NET Core Web API project
  • A running PostgreSQL instance for the Hangfire storage example
  • The Hangfire.AspNetCore and Hangfire.PostgreSql NuGet packages for the second half
Note: The two tools solve different problems. IHostedService is in-process and ephemeral, ideal for periodic in-memory tasks. Hangfire persists jobs to storage so they survive restarts and retry on failure, which is what you want for work that must not be lost. Choosing the wrong one is the most common mistake here.

2 A recurring task with IHostedService

For periodic in-process work, .NET 8 gives you BackgroundService, a base class over IHostedService that runs alongside your app for its entire lifetime. We will build a worker that purges expired refresh tokens every hour.

Implement the worker

The critical detail is scope. A hosted service is a singleton, but most useful work needs scoped dependencies like a DbContext. You must create a scope per iteration rather than injecting scoped services directly.

// TokenCleanupService.cs
public sealed class TokenCleanupService(
    IServiceScopeFactory scopeFactory,
    ILogger<TokenCleanupService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var removed = await db.RefreshTokens
                    .Where(t => t.ExpiresUtc < DateTime.UtcNow)
                    .ExecuteDeleteAsync(stoppingToken);

                logger.LogInformation("Purged {Count} expired tokens.", removed);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Token cleanup iteration failed.");
            }
        }
    }
}
// Program.cs
builder.Services.AddHostedService<TokenCleanupService>();

Three things make this production-safe: PeriodicTimer gives clean, allocation-light scheduling that respects the cancellation token; the per-iteration scope prevents the captured-dependency bug; and the try/catch ensures one failed run does not tear down the whole loop. Without that catch, a single transient database hiccup would silently kill the service for the rest of the process lifetime.


3 Durable jobs with Hangfire

IHostedService is great until the requirement becomes "this email must be sent even if the server restarts." That is Hangfire's domain. It persists every enqueued job to storage (PostgreSQL here), executes it on a background worker, and retries automatically on failure.

Register Hangfire

// Program.cs
builder.Services.AddHangfire(config => config
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UsePostgreSqlStorage(options =>
        options.UseNpgsqlConnection(
            builder.Configuration.GetConnectionString("Hangfire"))));

builder.Services.AddHangfireServer();

var app = builder.Build();
app.UseHangfireDashboard("/jobs"); // protect this in production

Hangfire creates its own schema in PostgreSQL on first run. The dashboard at /jobs is a genuinely useful operational window, but it exposes job data, so wrap it in an authorization filter before shipping; an unprotected dashboard is a real security risk.


4 Enqueue and schedule work

With Hangfire registered, queueing work from a controller is a single call that returns immediately. The job runs out-of-band on a worker, and the HTTP response is fast.

// In a controller, after registering a user
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
{
    var user = await _userService.CreateAsync(dto);

    // Fire-and-forget: returns instantly, runs in the background.
    _backgroundJobs.Enqueue<IEmailSender>(
        sender => sender.SendWelcomeEmailAsync(user.Email));

    return Ok(new { user.Id });
}

Hangfire also handles delayed and recurring jobs. A nightly report or a "remind me in 24 hours" email needs no custom timer code:

// Delayed: run once, later
_backgroundJobs.Schedule<IEmailSender>(
    sender => sender.SendReminderAsync(userId),
    TimeSpan.FromHours(24));

// Recurring: cron-scheduled, survives restarts
_recurringJobs.AddOrUpdate<IReportService>(
    "nightly-report",
    service => service.GenerateDailyReportAsync(),
    Cron.Daily(2)); // 2 AM every day

Because Hangfire passes a type and an expression rather than an instance, it resolves your service from DI at execution time and serializes only the arguments. Keep those arguments small and serializable; passing entire entities is a common pitfall that bloats storage and breaks when the type changes.


5 Running and verifying

Start the app and register a user. The HTTP response returns immediately, then the welcome-email job appears in the Hangfire dashboard, moving from Enqueued to Processing to Succeeded. The hosted cleanup service logs its purge count once an hour.

// Make a job fail deliberately to watch retries in the dashboard
public Task SendWelcomeEmailAsync(string email)
{
    if (string.IsNullOrWhiteSpace(email))
        throw new InvalidOperationException("No email address.");
    // ... send logic
    return Task.CompletedTask;
}

Throw an exception on purpose and you will see Hangfire move the job to the Failed state and then retry it on a back-off schedule, all visible in the dashboard. That automatic retry, with full visibility, is precisely what a hand-rolled timer cannot give you and the main reason to reach for Hangfire when work must not be lost.

Idempotency matters: because Hangfire retries, a job can run more than once. Design jobs so a second execution is harmless (check whether the email was already sent, use upserts, guard with a dedupe key). Assuming exactly-once execution is the single biggest source of background-job bugs.

Wrapping up

You now have both tools in your kit: IHostedService for lightweight, in-process recurring work, and Hangfire for durable, retryable, observable jobs backed by PostgreSQL. The decision rule is simple: if losing the work is acceptable, a hosted service is enough; if it is not, persist it with Hangfire.

Next we tackle another lever for responsiveness and cost: caching, looking at in-memory, distributed, and response caching strategies in a .NET 8 API.

Got a question or ran into a problem? Drop a comment below and I will reply.

No comments: