Skip to main content

Command Palette

Search for a command to run...

I Replaced Every Polling Hangfire Job With RabbitMQ (And What I'd Warn You About)

Updated
6 min read
G
Software Engineer by day, indie hacker by night. Building SaaS products in public, exploring AI, productivity, and knowledge management. Currently building SavePosty and Launch Tracker. Alternative (More Personal) 25+ years in software engineering. Building simple tools that help people save time, stay organized, and get more done. Sharing the lessons, wins, mistakes, and experiments from building SaaS products in public. Alternative (Founder Focused) Full-stack engineer, entrepreneur, and lifelong builder. Writing about SaaS, AI, product development, and the realities of launching products as a solo founder. Currently building SavePosty and Launch Tracker. Alternative (My Favorite) Software Engineer by day, builder by night. Documenting the journey of creating profitable SaaS products, growing an audience, and turning ideas into products people actually use. Currently building SavePosty and Launch Tracker.

Hangfire is one of those libraries that makes you look smart for almost no effort. Drop in a NuGet package, point it at the database you already have, get a dashboard for free. For a single .NET service, it's genuinely hard to beat.

So why did I rip it out of an entire app?

Because I wasn't running one job. I was running all of them this way — and every single one was knocking on Postgres every few seconds asking "anything for me yet?"

This is the story of moving every polling Hangfire job in SavePosty to RabbitMQ. The wins are real. So is the one thing you lose. Here's the honest version.

Where I started

Across the app, Hangfire ran all my background work, backed by PostgreSQL — email sends, webhook dispatch, content re-fetch, and everything else. Different jobs, same mechanism: each one discovered work by polling the database on a timer.

For a long time that was completely fine. I want to be fair to Hangfire here, because the migration write-ups 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, stop reading and just use Hangfire. You'll be happy.

The two things that tipped me over

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? Invisible. But I had many jobs, all polling the same database, all the time. That's constant read load that grows with your poll frequency and your job count — not with actual throughput. It's a tax you pay even when the app is idle.

  2. Two job mechanisms in one system. One of my services, PostyFetch, already ran on RabbitMQ. Everything else was still polling Hangfire. So I was maintaining two completely different models for "do work in the background" — two mental models, two sets of 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 the latency difference is small, but combined with the two reasons above, it was the obvious direction.

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

Here's the part that actually made a multi-job migration safe instead of terrifying.

Each RabbitMQ consumer does as little as humanly possible: receive a message, deserialize it, and delegate to the existing job class. No business logic moved. The code that sends an email or dispatches a webhook stayed exactly where it was, still independently testable. The consumer is just a new front door in front of unchanged logic.

I used the identical shape for every job I migrated. That repetition is the point — it makes each migration a small, reviewable, rollback-able diff instead of a rewrite.

// 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. This is the easy thing to get wrong, so I did it before anything else. Hangfire's [AutomaticRetry(Attempts = 0)] became MaxRetries = 0 on the consumer. Get this mapping 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 HangfireJobId column 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, you need a plan for that gap before you cut over, not after. I now reach for logs and DLQ replay instead of a pretty per-job screen, and that was an adjustment.

So… 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.


This is part of building SavePosty in the open — a faster, smarter home for everything you save. If the internals are your thing, come follow the build.