Hangfire Alternative in .NET: BackgroundService and Channel
A developer explains how a rewrite dropped Hangfire entirely in favor of two BackgroundService workers fed by channels, and why most recurring jobs turned out to be a query filter instead. It is a practical comparison of when you still need Hangfire or Quartz.NET and when the built-in runtime is enough.
Key points
- Hangfire was reduced to zero tables: the author's database holds 31 tables and none belong to it.
- Scheduled publishing moved to an EF Core expression that requires IsPublished and ScheduledAt <= utcNow, removing the every-minute job and its lag.
- The view tracker uses Channel.CreateBounded with a 10,000 capacity and DropWrite, so bursts drop rows instead of blocking HTTP requests.
- The worker flushes up to 200 events every 5 seconds in a new DI scope and uses ExecuteUpdateAsync for one UPDATE per post, not per view.
- Catch exceptions inside ExecuteAsync: BackgroundServiceExceptionBehavior.StopHost is the .NET 6+ default and shuts the host down.
- PeriodicTimer covers simple fixed-interval jobs; Quartz.NET or Hangfire stay right for durable, retried, multi-server work.
A .NET developer walks through removing Hangfire from an ASP.NET Core site and replacing it with two BackgroundService classes fed by bounded System.Threading.Channels. Most of the five recurring jobs turned out to be unnecessary: scheduled publishing became an EF Core WHERE clause on ScheduledAt, backups moved to the hosting panel, and cleanup moved inside a worker loop. Only page-view tracking and real-user timing data stayed as real background work.
The pattern is small but has sharp edges. The channel is created with Channel.CreateBounded and FullMode = DropWrite, so a spike drops analytics rows instead of blocking requests. The worker collects up to 200 events for 5 seconds, then writes one batch inside a fresh DI scope, because hosted services are singletons while DbContext is scoped. Exceptions must be caught inside the loop: since .NET 6 an unhandled one in ExecuteAsync stops the host. What you give up is durability, retries and the dashboard.
Real-world use
- A public blog or content site that writes a view counter or analytics event on every page request without making readers wait on a remote INSERT.
- A busy ASP.NET Core API that batches high-volume, low-value writes (telemetry, timing data, log rows) and can afford to drop a few under load.
- Replacing a scheduler used only for scheduled publishing with a query-time filter, so posts go live with no job and no minute of lag.
- A nightly cleanup job on a single-server app that keeps a retention window (for example deleting rows older than 90 days) without a job library.
Why it matters
Adding a job library brings its own database, polling traffic, retries and a dashboard to monitor. Check each scheduled job first: if it can be expressed as a time-aware query or a short-lived in-process task, you remove infrastructure instead of managing it.