Background Jobs
By the end of this lesson you'll be able to move slow work off the web request and onto a background worker — enqueuing jobs, processing them in a loop, running things on a recurring schedule, and reaching for BackgroundService , Hangfire , or Quartz.NET when you need persistence, retries, and idempotency.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Picture a busy restaurant. When you order, the waiter doesn't stand at your table cooking your meal — that would block them from serving anyone else. Instead they clip your ticket onto a rail in the kitchen and walk away. The chefs work that rail of tickets in order, one after another, at their own pace. A web request is the waiter: it should take the order (enqueue a job) and return to the customer instantly. The background worker is the chef: it pulls tickets off the queue and does the slow cooking. Some tickets are "make this now" (fire-and-forget), some say "fire the dessert in 10 minutes" (delayed), and the daily prep list runs every morning whether anyone ordered it or not (recurring). The whole point is that the dining room never waits on the stove.
A web request should finish in milliseconds. The moment you do something slow inside the request — send an email, resize an image, call a flaky third-party API, build a PDF — the user sits there watching a spinner, your thread is tied up, and a timeout or a crash loses the work entirely.
The fix is to offload : the request records "this needs doing" and returns immediately; a separate worker does the slow part later. That gives you three big wins — fast responses, work that survives a slow downstream service, and the ability to retry failures without the user ever knowing.
Background work comes in three shapes you'll use again and again:
Rule of thumb: start with BackgroundService for in-process work, reach for Hangfire the moment jobs must survive a restart or retry on failure, and pick Quartz.NET only when you need its advanced scheduling or clustering.
1. Queues and Workers — the core idea
Strip away the frameworks and every job system is the same two pieces: a queue of pending work and a worker that drains it. A Queue<string> is a first-in, first-out line — Enqueue adds to the back, Dequeue removes from the front. The request side enqueues and returns instantly; the worker side loops, pulling one job at a time. Read this worked example, run it, then you'll build the same loop yourself.
Your turn. The program below queues three jobs and processes them — but three pieces are missing. Fill in the ___ blanks using the hints, then run it.
2. Recurring Schedules — running only when due
A recurring job shouldn't run every time the worker wakes up — only when its interval has elapsed. The pattern is a small piece of bookkeeping: count how long it's been since the last run, and when that count reaches the interval, run the job and reset the counter. Real schedulers do this with CRON expressions and timestamps, but the logic is identical. Fill in the two ___ blanks below to make a job that fires every third tick.
3. IHostedService & BackgroundService
.NET has the worker loop built in. IHostedService is the interface for "something that starts when the app starts and stops when it stops"; BackgroundService is the convenient base class — you just override ExecuteAsync and write your loop. Pair it with a Channel<T> (a thread-safe async queue) and a controller can enqueue work that the worker drains in the background. Note the cancellation token and the try/catch: a real worker must shut down gracefully and must never die because one job threw.
4. Hangfire — persistent jobs with retries
A plain BackgroundService keeps its queue in memory, so a restart loses every pending job. Hangfire fixes that by storing jobs in a database: they survive restarts, retry automatically when they throw, and show up in a built-in dashboard at /hangfire . All three job shapes have a one-liner — BackgroundJob.Enqueue for fire-and-forget, BackgroundJob.Schedule for delayed, and RecurringJob.AddOrUpdate (with a stable id) for recurring.
5. Quartz.NET — enterprise scheduling
Quartz.NET is the heavyweight option. It splits the job (a class implementing IJob ) from the trigger (when it fires), so one job can have many triggers, each with a full CRON expression and misfire handling for runs missed during downtime. It also supports clustering, so a job runs on exactly one node in a farm. Reach for it when Hangfire's scheduling isn't expressive enough — otherwise Hangfire is simpler.
Background jobs will run more than once. A worker can crash after doing the work but before marking it done, so the job runs again on retry. That means every job must be idempotent — running it twice has the same effect as running it once. "Charge the card" is dangerous; "charge the card if this order isn't already paid " is safe.
Retries are your safety net for transient failures (a network blip, a locked row). Hangfire and Quartz retry automatically; in a hand-rolled worker you add it yourself, usually with exponential backoff (wait 1s, then 2s, then 4s…). After a few failures a job is "poison" — move it to a dead-letter store and alert, rather than retrying forever.
Persistence is what makes all this survive a restart. A queue in memory vanishes when the process dies; a queue in a database (Hangfire) or on a broker (RabbitMQ, Azure Service Bus) is still there when the app comes back. If losing a job would matter, the queue must be persistent.
Q: Can't I just use Task.Run to do work in the background?
For a quick, fire-and-forget bit of work it sometimes seems fine, but it's risky: the work isn't persisted, won't retry, and can be killed mid-flight when the app recycles. Use a real job system ( BackgroundService + a queue, or Hangfire) so work survives and retries.
Q: What does "idempotent" actually mean here?
A job is idempotent if running it twice has the same effect as running it once. Because retries can re-run a job, design each one so a repeat is harmless — usually by checking whether the work is already done before doing it.
Q: BackgroundService or Hangfire — how do I choose?
Use BackgroundService for simple, in-process work where losing a job on restart is acceptable. The moment jobs must survive restarts, retry automatically, or be visible in a dashboard, switch to Hangfire.
Q: Why pass an ID into a job instead of the whole object?
Hangfire and Quartz serialise job arguments to the database. A big object can fail to serialise or go stale by the time the job runs. Pass a small ID and reload the current data inside the job.
A job that fails every time it runs — bad data, a permanent error. Retrying it forever wastes resources, so after a few attempts you move it to a dead-letter store and alert a human, instead of looping.
No blanks this time — just a brief and an outline. Build a tiny scheduler that holds a few jobs, each with its own interval, and on every tick runs the ones that are due while skipping the rest. This is exactly the bookkeeping a real recurring scheduler does. Run it and check your output against the expected lines in the comments.
Practice quiz
At its core, every background job system is built from which two pieces?
- A database and a web server
- A thread pool and a mutex
- A queue of pending work and a worker that drains it
- A controller and a view
Answer: A queue of pending work and a worker that drains it. Strip away the frameworks and every job system is a queue of pending work plus a worker that processes it one item at a time.
A Queue<T> processes items in which order?
- First-in, first-out (FIFO)
- Last-in, first-out (LIFO)
- Random order
- Sorted order
Answer: First-in, first-out (FIFO). A Queue<T> is FIFO: Enqueue adds to the back and Dequeue removes from the front, so jobs come out in the order they went in.
Which Queue<T> method removes and returns the item at the front?
- Enqueue()
- Push()
- Pop()
- Dequeue()
Answer: Dequeue(). Dequeue() removes and returns the oldest item (front of the line); Enqueue() adds to the back.
Which are the three common shapes of background work?
- Read, write, delete
- Fire-and-forget, delayed, and recurring
- Sync, async, parallel
- Create, update, destroy
Answer: Fire-and-forget, delayed, and recurring. The three shapes are fire-and-forget (once, soon), delayed (once, after a wait), and recurring (on a schedule).
In .NET, which base class gives you a long-running in-process worker by overriding ExecuteAsync?
- BackgroundService
- BackgroundJob
- Task
- HostedWorker
Answer: BackgroundService. BackgroundService is the convenient base class for IHostedService — you override ExecuteAsync and write your loop.
What does Hangfire add over a plain in-memory BackgroundService?
- Nothing — they are identical
- Faster CPU computation
- Persistence to a database, automatic retries, and a dashboard
- Built-in HTTP routing
Answer: Persistence to a database, automatic retries, and a dashboard. Hangfire stores jobs in a database so they survive restarts, retries them automatically on failure, and provides a dashboard.
What does it mean for a job to be idempotent?
- It runs only on idle CPUs
- Running it twice has the same effect as running it once
- It never throws an exception
- It runs faster the second time
Answer: Running it twice has the same effect as running it once. Because retries can re-run a job, each must be idempotent — a second run should be harmless, often guarded by a check like 'if (order.IsPaid) return;'.
Why should you pass an ID into a Hangfire/Quartz job rather than a full object?
- IDs are encrypted automatically
- Objects cannot be passed to methods in C#
- IDs make the job run on a separate machine
- Job arguments are serialised, so a big object can fail to serialise or go stale; reload it by ID inside the job
Answer: Job arguments are serialised, so a big object can fail to serialise or go stale; reload it by ID inside the job. Hangfire and Quartz serialise job arguments. Pass a small ID and reload the current data inside the job to avoid stale or unserialisable objects.
In Quartz.NET, the job and the schedule are separated into which two concepts?
- Service and controller
- The job (IJob) and the trigger (when it fires)
- Producer and consumer
- Queue and worker
Answer: The job (IJob) and the trigger (when it fires). Quartz splits the job (a class implementing IJob) from the trigger that decides when it fires, so one job can have several triggers.
What is a 'poison' message/job?
- A job that runs too quickly
- A job that uses too much memory once
- A job that fails every time it runs, so retrying forever wastes resources
- A job with no name
Answer: A job that fails every time it runs, so retrying forever wastes resources. A poison job fails on every attempt (bad data, permanent error). After a few attempts you move it to a dead-letter store and alert a human.