The first version of this site was an ASP.NET Core MVC app with Hangfire wired in: a dashboard at /hangfire, five recurring jobs, ten places in the code that called BackgroundJob.Enqueue, and a second SQL Server database just to hold Hangfire's job tables. When I rewrote the backend as a separate Web API, I didn't bring Hangfire with me. The API that serves this page runs two BackgroundService classes fed by System.Threading.Channels, and one WHERE clause does the work of the every-minute job.

This isn't a "Hangfire is bad" post. Hangfire is a good library. It's a post about checking which jobs actually needed it, because for this site the answer was none of them.

The short version: Use Hangfire (or Quartz.NET) when a job must survive a restart, retry on its own, or be visible on a dashboard. If the work is in-process, short-lived and fine to lose on a crash, a BackgroundService with a bounded Channel is built into .NET, needs no extra database, and is about 60 lines. A job that only "flips something on at a certain time" often needs no job at all: filter by time in the query.

What Hangfire was doing on the old site

The setup was the standard one from Hangfire's docs, with SQL Server storage on its own connection string:

// Program.cs of the old MVC site (connection string name only - the value lives in config) var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection"); builder.Services.AddHangfire(c => c .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UseSqlServerStorage(hangfireConnectionString, new SqlServerStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(15), DisableGlobalLocks = true, })); builder.Services.AddHangfireServer(); // ...and after app.Build(): RecurringJob.AddOrUpdate("check-scheduled-posts", () => blogPostService.PublishScheduledPostsAsync(), "* * * * *"); 

Here is every job it had, and what each one became in the new API:

Hangfire job (old site) Schedule What replaced it
check-scheduled-posts Every minute Nothing runs. The query filters on ScheduledAt <= now
daily-backup 02:00 daily The hosting panel's scheduled backup, outside the app
update-sitemap Recurring A Next.js route handler that regenerates at most once an hour
cleanup-expired-comments, cleanup-old-logs Recurring Dropped. The only cleanup left is inside a worker loop (below)
Fire-and-forget: welcome and reset emails, newsletter sends, image optimisation, analytics Enqueued per request Emails are sent inside the request. Page views go through a Channel

When I checked the API's database on 2 November, it had 31 tables and none of them belonged to Hangfire. The separate Hangfire database is only referenced by the old app's config.

Why I didn't keep it

Three reasons, in the order they mattered.

The database is on remote shared hosting. Every query this site makes crosses the internet to a shared SQL Server. Hangfire's SQL Server storage polls its queue table, and with QueuePollInterval at 15 seconds that's 5,760 polls a day per worker, whether or not there's any work, plus the heartbeat and recurring-job checks. On a dedicated server nobody would notice. On a shared host with a round trip on every call, it's background load for nothing.

Most of the jobs didn't need a scheduler. The every-minute job looked for posts whose publish time had passed and flipped them live. But a post doesn't have to be flipped. If every public query asks "is this post published and is its time in the past?", the post becomes visible at the right moment on its own:

// A post is live when it is published AND its scheduled time has passed. // EF Core translates this to a WHERE clause, so "publishing on schedule" needs no job at all. public static Expression<Func<BlogPost, bool>> IsLive(DateTime utcNow) => p => p.IsPublished && p.PublishedAt != null && (p.ScheduledAt == null || p.ScheduledAt <= utcNow); 

The API's public post queries (lists, tags, authors, the single post) all go through that expression. There's no job to fail, no minute of lag, and nothing to monitor.

A second database is a second thing to back up, restore and secure. When this site's main database was restored from a backup on 29 October, I had to work out which posts the restore had wiped. With Hangfire's state in another database, a restore of one without the other leaves jobs that point at rows that no longer exist. One database is simpler.

The replacement: BackgroundService plus a bounded Channel

The job that most needed to run in the background was the cheapest one: counting page views. Writing a view row on every post request meant the reader waited on a remote INSERT before the post came back. Now the endpoint drops an event on a channel and returns:

public readonly record struct PostViewEvent(int PostId, string? IpAddress, string? UserAgent); public sealed class ViewTracker { private readonly Channel<PostViewEvent> _channel = Channel.CreateBounded<PostViewEvent>( new BoundedChannelOptions(10_000) { // Under a spike, lose a few analytics rows rather than block a request. FullMode = BoundedChannelFullMode.DropWrite, SingleReader = true, }); public ChannelReader<PostViewEvent> Reader => _channel.Reader; // Called from the GET /posts/{slug} endpoint. Never awaits, never touches the database. public void Track(PostViewEvent view) => _channel.Writer.TryWrite(view); } 

A BackgroundService reads the channel, waits up to 5 seconds or 200 events, and writes the batch in one go:

public sealed class ViewTrackerWorker( ViewTracker tracker, IServiceScopeFactory scopeFactory, ILogger<ViewTrackerWorker> logger) : BackgroundService { private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5); private const int MaxBatch = 200; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var buffer = new List<PostViewEvent>(MaxBatch); while (!stoppingToken.IsCancellationRequested) { try { // Wait for the first event, then collect more for up to 5 seconds or 200 items. if (!await tracker.Reader.WaitToReadAsync(stoppingToken)) break; using var window = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); window.CancelAfter(FlushInterval); try { do { while (buffer.Count < MaxBatch && tracker.Reader.TryRead(out var view)) buffer.Add(view); } while (buffer.Count < MaxBatch && await tracker.Reader.WaitToReadAsync(window.Token)); } catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) { // The 5-second window ran out - the normal path. } await FlushAsync(buffer, stoppingToken); buffer.Clear(); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { // One bad batch must not kill the loop - an unhandled exception here stops the // service, and since .NET 6 it stops the whole host by default. logger.LogError(ex, "Failed to flush post views"); buffer.Clear(); await Task.Delay(FlushInterval, stoppingToken); } } } private async Task FlushAsync(List<PostViewEvent> buffer, CancellationToken ct) { // A BackgroundService is a singleton; DbContext is scoped. Make a scope per batch. using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); db.PostViews.AddRange(buffer.Select(v => new PostView { BlogPostId = v.PostId, ViewedAt = DateTime.UtcNow })); await db.SaveChangesAsync(ct); // One UPDATE per post, not one per view. foreach (var group in buffer.GroupBy(v => v.PostId)) { var n = group.Count(); await db.BlogPosts.Where(p => p.Id == group.Key) .ExecuteUpdateAsync(s => s.SetProperty(p => p.ViewCount, p => p.ViewCount + n), ct); } } } 

Registration is two lines. The tracker is a singleton so the endpoint and the worker share the same channel:

builder.Services.AddSingleton<ViewTracker>(); builder.Services.AddHostedService<ViewTrackerWorker>(); 

The second worker, for the site's real-user speed data, is the same pattern with a 20,000-event channel and 300-event batches. It also deletes rows older than 90 days, once a day, from inside the same loop. That one check replaced the two cleanup jobs I had in Hangfire.

Details that matter

  • Bounded, not unbounded. Channel.CreateUnbounded grows until the process runs out of memory if the database is down for long. A bound plus DropWrite loses analytics rows instead, which is the right failure for a view counter.
  • Catch everything inside the loop. Since .NET 6, an unhandled exception in ExecuteAsync stops the host by default (BackgroundServiceExceptionBehavior.StopHost). One failed batch shouldn't take the API down.
  • Create a scope per batch. Hosted services are singletons and DbContext is scoped. Injecting the context directly into the worker either fails at startup or keeps one context alive forever.
  • Batch the UPDATE too. ExecuteUpdateAsync runs one UPDATE ... SET ViewCount = ViewCount + n per post in the batch, not one per view.

What I gave up

This only works because I accepted these, on purpose:

  • Anything in the channel is lost on a restart or crash. Up to 5 seconds of page views. For a view counter that's fine. For a payment webhook or an order email it is not.
  • No retries. Hangfire retries a failed job automatically (10 attempts by default). My worker logs the error and drops the batch.
  • No dashboard. Failures show up in the log, not on a page.
  • Emails run inside the request. A contact form submission waits for two SMTP sends before the visitor sees "sent". At this site's volume that's acceptable. With a slow mail server or real traffic, it wouldn't be, and that is the first thing I'd move back to a queue.
  • Scheduled posts aren't announced when they go live. The API pings IndexNow when a post is saved, but only if it's already live. A post scheduled for tomorrow becomes visible tomorrow through the query filter, and nothing tells Bing. I submit those by hand. A scheduler would have handled that.
  • IIS can stop the worker. A BackgroundService lives inside the app pool. If IIS idles the pool out, the worker stops until the next request. That's harmless for work that's created by requests, but a daily timer won't fire on a site nobody visited. Hangfire on IIS has exactly the same problem, which is why its docs tell you to configure the app to always run.

Hangfire vs Quartz.NET vs BackgroundService

Hangfire Quartz.NET BackgroundService + Channel
Survives a restart Yes (storage: SQL Server, Redis and others) Yes, with the ADO.NET job store No
Automatic retries Yes No, you handle it in the job No, you write it
Dashboard Built in Not built in No
Cron schedules Yes Yes, the strongest of the three PeriodicTimer for fixed intervals
Extra storage Its own tables (or database) Its own tables, if persistent None
Cost Free (LGPL), Pro is paid Free (Apache 2.0) Built into .NET

If you only need a recurring job and can live with it not running while the app is down, PeriodicTimer covers it without a library:

// Generic example, not from this site: a daily job without Hangfire. public sealed class NightlyCleanup(IServiceScopeFactory scopes, ILogger<NightlyCleanup> logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TimeSpan.FromHours(24)); do { try { using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var cutoff = DateTime.UtcNow.AddDays(-90); await db.Logs.Where(l => l.At < cutoff).ExecuteDeleteAsync(stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Nightly cleanup failed"); } } while (await timer.WaitForNextTickAsync(stoppingToken)); } } 

When I'd still pick Hangfire

  • Work that must not be lost: payments, order confirmations, anything a customer would notice was missing.
  • Jobs that call flaky third-party APIs and need retries with backoff.
  • More than one server, where only one of them should run a job.
  • A team that needs to see and re-run failed jobs without reading logs.

If none of those apply, look at each job and ask whether it needs a scheduler at all, or just a query that checks the time. I wrote more about how the MVC site became a separate API and front end in the migration post, and about keeping the API fast on remote hosting in the Web API performance post.

FAQ

What is a good Hangfire alternative in .NET Core?

For in-process work that can be lost on a restart, the built-in BackgroundService with a bounded System.Threading.Channels queue needs no library and no database. For persistent, cron-scheduled jobs, Quartz.NET is the usual free alternative. Hangfire is still the easiest choice when you need retries and a dashboard.

Hangfire vs BackgroundService: which should I use?

Use BackgroundService when the work is short, created by requests and fine to lose on a crash, such as view counters or log batching. Use Hangfire when a job must survive restarts, retry on failure or be visible on a dashboard, such as emails tied to orders or payments.

Hangfire vs Quartz.NET: what is the difference?

Hangfire is built around fire-and-forget and delayed jobs with automatic retries and a built-in dashboard. Quartz.NET is a scheduler first, with the most flexible cron and calendar triggers, but no built-in dashboard and no automatic retries. Both can persist jobs to a database.

How do I make a background task queue in ASP.NET Core?

Register a singleton that wraps a bounded Channel, write to it from your endpoints with TryWrite, and read from it in a BackgroundService registered with AddHostedService. Create a DI scope per batch to get a DbContext, and catch exceptions inside the loop so one failure does not stop the host.

Does Hangfire need its own database?

No. Hangfire can store its tables in your application's database, under its own HangFire schema, or use a separate database or Redis. Either way it adds tables that have to be backed up and kept in step with your data.

Do I need a job to publish scheduled blog posts?

Not necessarily. If every public query filters on the scheduled time, for example ScheduledAt <= now, the post appears at the right moment with no job. A job is only needed if something else must happen at that moment, such as notifying search engines or sending emails.

Will a BackgroundService keep running on IIS?

Only while the application pool is running. IIS stops idle pools by default, which stops hosted services too. Work created by requests is fine, but timers need the app pool set to always running, the same requirement Hangfire has on IIS.

Originally published on knowledgemarkg.com.