Hangfire makes you look smart for almost no effort: drop in a NuGet package, point it at the database you already have, and you get a job runner and a dashboard for free. For a single .NET service, it's genuinely hard to beat.
So why did I pull it out of an entire app?
Because I wasn't running one job. I was running all of them through Hangfire — and every single one was hitting Postgres every few seconds asking "anything for me yet?"
This is the story of moving every polling Hangfire job in SavePosty — a read-it-later app I'm building — to RabbitMQ: the wins, the migration pattern that kept it boring, and the one thing you lose. The honest version.
Disclosure: I'm the founder of SavePosty. This is a write-up of a real migration in our own codebase, not a sponsored post — every example below is from production.
What I was running before
Across the app, Hangfire ran all my background work, backed by PostgreSQL — email sends, webhook dispatch, content re-fetch, and the rest. Different jobs, same mechanism: each one discovered work by polling the database on a timer.
Credit where it's due, because the migration posts that pretend the old tool was garbage are lying to you. Hangfire gave me:
- Zero new infrastructure — it rides on the Postgres I already run.
- A dashboard out of the box: queued, processing, succeeded, failed.
- Retry logic in a single attribute.
If you're one service with a small team, honestly, just use Hangfire. You'll be happy.
Why I migrated
I didn't leave because Hangfire was bad. I left because my situation changed.
1. Polling load that scales with the clock, not the work. Every Hangfire job hits Postgres on an interval whether or not there's anything to do. One job is invisible. But I had many jobs, all polling the same database, all the time — constant read load that grows with your poll frequency and your job count, not with actual throughput. You pay it even when the app is idle.
2. Two job mechanisms in one system. One service, PostyFetch, already ran on RabbitMQ. Everything else still polled Hangfire. So I maintained two completely different models for "do work in the background" — two mental models, two runbooks, two places to look when something broke. Moving everything onto the broker collapsed that into one.
A broker also pushes work the moment it arrives instead of waiting for the next poll. For most jobs that latency win is small, but stacked on the two reasons above, the direction was obvious.
The honest tradeoff
No migration is free. If this table makes Hangfire look easy, that's because — for a single service — it is.
| Hangfire | RabbitMQ | |
|---|---|---|
| Setup | Drop-in NuGet | A broker to deploy & manage |
| Persistence | SQL table | Queues + dead-letter queues |
| Dashboard | Built-in | Management plugin |
| Retry config | Attribute-level | Consumer-level MaxRetries
|
| Dev experience | Easy local | Needs Docker / a container |
| Operational cost | Polling DB load (per job) | Broker RAM + network |
| Message requeue | By job ID | Queue-level (no per-message) |
The case for RabbitMQ isn't "it's better." It's "it gets better with every extra service and every extra job that would otherwise be polling."
The pattern that kept it boring: thin consumers
This is the part that made a multi-job migration safe instead of terrifying.
Each RabbitMQ consumer does as little as possible: receive a message, deserialize it, and delegate to the existing job class. No business logic moves. The code that sends an email or dispatches a webhook stays exactly where it was, still independently testable. The consumer is just a new front door in front of unchanged logic — and I used the identical shape for every job I migrated.
// The consumer is a thin shell. It owns transport, not behavior.
public class SendEmailConsumer : IConsumer<SendEmailMessage>
{
private readonly SendEmailJob _job; // the SAME class Hangfire used
public SendEmailConsumer(SendEmailJob job) => _job = job;
public async Task Consume(ConsumeContext<SendEmailMessage> context)
{
// No logic here. Just hand off.
await _job.Execute(context.Message.EmailId);
}
}
Two things I got right by doing them first:
-
Retry parity. The easy thing to get wrong, so I did it before anything else. Hangfire's
[AutomaticRetry(Attempts = 0)]becameMaxRetries = 0on the consumer. Get this wrong and you either hammer a failing downstream forever or silently drop messages that should have retried. -
No breaking schema change. I left the old
HangfireJobIdcolumn nullable. Old rows keep their IDs, new rows leave it null, and the data layer never needed a coordinated deploy.
Reliability: a dead-letter queue for every queue
Every job got its own queue and its own DLQ — postysend.sendemail.dlq, postysend.webhookdispatch.dlq, and so on. When a message exhausts its retries, it lands in its DLQ instead of evaporating.
There's a quiet upgrade hiding here too: because jobs now run as consumers, they can publish messages themselves. I moved one job from IBackgroundJobClient to IMessagePublisher, so sending an email now publishes a WebhookDispatch message directly. That kind of service-to-service chaining is awkward in Hangfire and completely natural on a broker.
The thing you actually lose
Here's the part most "we migrated and it's amazing" posts skip.
You lose per-message failed-job inspection. Hangfire's dashboard let me open a single failed job and read exactly what happened. RabbitMQ's management view gives me DLQ counts — how many messages are dead-lettered, not the full story of each one without going and reading the queue directly.
That's a real downgrade. If your debugging workflow leaned on opening one bad job at a time, plan for that gap before you cut over, not after. I now reach for structured logs and DLQ replay instead of a pretty per-job screen, and that was an adjustment.
Should you do this?
Be honest with yourself.
Stay on Hangfire if: you run a single service with a small team, you have no broker, you need the dashboard and per-job inspection, and DB polling load just isn't a problem at your scale. None of those are embarrassing reasons — they're good engineering.
Move to RabbitMQ if: you have multiple services, or enough jobs that polling the database is its own measurable cost; you already run (or can run) a broker; or you want pub/sub and fan-out, which Hangfire fundamentally can't do.
The decision was never about a single job. It was about the system. RabbitMQ wins when you're coordinating many moving parts; Hangfire wins when you're not.
I'm building SavePosty in the open — a faster, smarter home for everything you save. If the internals are your thing, come follow the build. Questions about the migration? Drop them in the comments.
Top comments (0)