Hangfire Dependency Injection: Scoped Services, DbContext, and Disposed Objects
Contents
With Hangfire.AspNetCore, every background job runs in its own dependency injection scope. The worker creates the scope, resolves the job class from it, runs the method, and disposes the scope once the method (or the Task it returns) finishes, so a scoped DbContext injected into the job's constructor is safe. "Cannot access a disposed object" means the job reached a service from some other scope, usually an HTTP request's, or kept using its own scope's services after the method returned.
This guide covers Hangfire.AspNetCore 1.8.25, the current release on NuGet (checked October 30, 2026), with Hangfire.Core of the same version. .NET and EF Core source links point to the v9.0.22 tags, the latest .NET 9 patch in both repositories on the same date. Retry counts and delays are covered in Hangfire automatic retry, jobs that never start in why a Hangfire recurring job is not running, and scoped services in a plain hosted service in .NET BackgroundService scheduled tasks.
How Hangfire.AspNetCore Activates a Job#
AddHangfire builds Hangfire's global configuration from the container. Before it runs your configuration callback, it installs an AspNetCoreJobActivator built from the container's IServiceScopeFactory, so a UseActivator call inside your callback replaces it (HangfireServiceCollectionExtensions.cs). AddHangfireServer hands that activator to the server unless BackgroundJobServerOptions.Activator is already set (same file). The activator lives in the Hangfire.NetCore package, which Hangfire.AspNetCore references.
What happens for each run, from the source:
- A new scope per execution.
CoreBackgroundJobPerformer.Performopensusing (var scope = _activator.BeginScope(context))around the whole run (CoreBackgroundJobPerformer.cs), andBeginScopereturns a wrapper around_serviceScopeFactory.CreateScope()(AspNetCoreJobActivator.cs). - The job type is resolved from that scope.
ResolvecallsActivatorUtilities.GetServiceOrCreateInstance(_serviceScope.ServiceProvider, type)(AspNetCoreJobActivatorScope.cs). That method returns the registered service, and "if one is not found then instantiate it directly", filling the constructor from the same scope. A job class doesn't have to be registered. - Static methods get no instance.
PerformcallsResolveonly when the job method isn't static. A static job method gets nothing from the container. - Async jobs keep the scope until their task completes. For a method that returns
Task,InvokeMethodblocks until the task finishes beforePerformreturns (CoreBackgroundJobPerformer.cs). - The scope is then disposed.
DisposeScopecallsDisposeAsyncwhen the scope supports it andDisposeotherwise (AspNetCoreJobActivatorScope.cs), which disposes every scoped and transient disposable resolved during the run, including theDbContext.
So the lifetimes you registered behave like this inside a job:
| Registered as | Inside a Hangfire job |
|---|---|
Scoped (AddDbContext uses this) |
One instance per run, shared by everything resolved in that run, disposed when the run ends |
| Transient | A new instance per resolution; disposable ones are disposed with the run's scope |
| Singleton | One instance for the whole process, shared by every worker running at the same time |
| Not registered (the job class, commonly) | Constructed by ActivatorUtilities for each run, with constructor arguments from the run's scope |
Each retry is another call to Perform, so it gets a new scope too. So does each occurrence of a recurring job, because each occurrence is an ordinary background job.
The Expression Stores a Type, Not Your Object#
A common worry is that BackgroundJob.Enqueue(() => _mailer.SendAsync(id)) inside a controller drags the request's _mailer into the job. It doesn't. When the expression's target is a variable or field, Hangfire evaluates it only to read its runtime type and stores that type; a comment in the source says, "People consider that the whole object is serialized, this is not true" (Job.cs). At run time the worker resolves that type from the job's own scope.
The catch is which type gets stored: the concrete class behind _mailer, such as SmtpInvoiceMailer, not IInvoiceMailer. If only the interface is registered, the activator falls back to constructing the concrete class with ActivatorUtilities, not through your registration. Name the type explicitly and Hangfire stores that type instead:
// Stores IInvoiceMailer; the worker resolves it from the job's scope.
BackgroundJob.Enqueue<IInvoiceMailer>(mailer => mailer.SendAsync(invoiceId));What "Cannot Access a Disposed Object" Means in a Job#
Two messages show up, and they point at different things:
System.ObjectDisposedException: Cannot access a disposed object. Object name: 'IServiceProvider'.Something resolved a service from a scope that had already been disposed. The container's scope checks for disposal on everyGetServicecall and throws with the object nameIServiceProvider(ServiceProviderEngineScope.cs, ThrowHelper.cs, message text).System.ObjectDisposedException: Cannot access a disposed context instance.Something used aDbContextafter it was disposed, either by its scope or by your own code. EF Core throws this fromCheckDisposedwith your context's class name as the object name (DbContext.cs, CoreStrings.resx).
Because the job's own scope is always fresh, both mean the code reached outside it or outlived it. These are the causes, in the order they're usually found.
A Provider Captured From an HTTP Request#
ASP.NET Core creates a scope per request and exposes it as HttpContext.RequestServices, and "in apps that process requests, scoped services are disposed at the end of the request". A job runs on a Hangfire worker after the request has returned, so anything it reaches through the request's provider is already gone.
Static job methods are where this usually starts. Hangfire's Passing Dependencies page notes that a static method is restricted to your application's static context: new, service location, factories, or singletons. A static IServiceProvider assigned from a request is the version that breaks:
// Broken: Services holds the provider of the request that last set it.
public static class ReportRunner
{
public static IServiceProvider? Services;
public static void Run()
{
var db = Services!.GetRequiredService<AppDbContext>(); // throws once that request has ended
// ...
}
}
// In a controller action:
ReportRunner.Services = HttpContext.RequestServices;
BackgroundJob.Enqueue(() => ReportRunner.Run());The same failure comes from a singleton that caches a scoped service or a provider it was handed during a request. The fix is to make the job an instance method on a class that takes its dependencies in its constructor, as shown in the pattern below.
A Service Passed as a Job Argument#
Arguments are not handed to the job in memory. Hangfire evaluates each argument expression when you enqueue (Job.cs) and serializes it to JSON with Newtonsoft.Json for storage, so job => job.Run(_db) never gives the job the live context you passed. Hangfire rejects delegate and expression parameters outright ("Anonymous functions, delegates and lambda expressions aren't supported in job method parameters", Job.cs), so a Func<AppDbContext> factory can't be passed either.
Pass identifiers and plain values, as Hangfire's docs recommend: "store concrete values in an application database and pass their identifiers only to your background jobs." Let the job resolve its services from its own scope.
Three parameter types are filled in by the server instead: CancellationToken, IJobCancellationToken, and PerformContext. Whatever value was stored for them, Hangfire substitutes its own when the job runs (CoreBackgroundJobPerformer.cs, SubstituteArguments). The CancellationToken it passes is linked to both the server's shutdown and the job's abort (ServerJobCancellationToken.cs), and Hangfire's Using Cancellation Tokens page says that "when state change is detected or when shutdown is requested, the corresponding cancellation token is canceled." The same page writes CancellationToken.None in the job expression, since "any CancellationToken instance can be used, it will be replaced internally just before performing a background job."
Work That Outlives the Job Method#
The scope lasts exactly as long as the job method and the task it returns. Work the method starts but doesn't await keeps going after Perform has disposed the scope.
Hangfire blocks the obvious case: a job method declared async void is rejected when the job is created, with NotSupportedException "Async void methods are not supported. Use async Task instead." (Job.cs). It can't see async void code inside the method:
// Broken: List<T>.ForEach takes an Action<T>, so this lambda is async void.
// The job method can return before the sends finish, and the scope is disposed under them.
overdue.ForEach(async invoice => await mailer.SendAsync(invoice.Id));
// Also broken: nothing awaits these, so they can run after the scope is gone.
_ = auditLog.WriteAsync(runId);
Task.Run(() => db.SaveChangesAsync());List<T>.ForEach takes an Action<T>, and Microsoft's docs warn that "an async method that returns void can't be awaited" and that its caller can't catch exceptions it throws. Use a foreach loop with await, and await every task the job starts.
This case behaves differently from the others, and the two kinds of unawaited work fail differently:
- A discarded task, like the
WriteAsyncandTask.Runcalls above. When a method that returns a task throws, "the exception is stored in the returned task" and rethrown only when that task is awaited. Nothing awaits it, so the job method returns normally and Hangfire records the run as Succeeded: a Failed state is built only whenPerformthrows (Worker.cs). Nothing retries, nothing reaches the Failed page, and nothing records the exception unless your code logs it. - An
async voidlambda, like the one passed toForEach. It has no task to hold an exception. .NET posts the exception to theSynchronizationContextthat was current when the lambda started or, if there was none, throws it on the thread pool, which the runtime's source notes "will result in a crash" (AsyncVoidMethodBuilder.cs). Microsoft's docs say such exceptions "are likely to cause your application to fail", and an unhandled exception in a thread causes the application to terminate in most cases. AnObjectDisposedExceptionfrom a send that outlived the scope can take down the process hosting the Hangfire server, not only this job.
Disposing the Injected Context Yourself#
Wrapping an injected DbContext in using disposes it while the scope still hands the same instance to everything else in that run. EF Core's error text names this cause: "disposing a context instance that was resolved from dependency injection and then later trying to use the same context instance elsewhere." Microsoft's rule is that "services should never be disposed by code that resolved the service from the container". Remove the using and let the job's scope dispose it.
A Related Error: A Second Operation on This Context#
If the job class or one of its dependencies is registered as a singleton and takes a DbContext, the context is captured: resolving a scoped service from a singleton "causes the scoped service to behave like a singleton". Every run then uses that one context, including runs on other workers at the same moment. The usual symptom isn't a disposed object but EF Core's "A second operation was started on this context instance before a previous operation completed" (CoreStrings.resx), because a DbContext is not thread-safe. In the Development environment you get an earlier error instead: there, by default, "resolving a service from another service with a longer lifetime throws an exception" (scope validation), so the shared-context error shows up where that check isn't on. Task.WhenAll over several queries on one injected context fails the same way. Register job classes as scoped or transient, or leave them unregistered.
Why the Same Exception Comes Back on Every Run#
A fresh scope per run doesn't help when the bug is in the wiring. The static field in the example above still points at the disposed provider on the next attempt, and on the attempt after that, until the process restarts or another request overwrites it. Nothing changes between attempts, so the retries can't succeed:
- The first attempt throws
ObjectDisposedException. Hangfire wraps it inJobPerformanceException("An exception occurred during performance of the job.") on the way out of the performer (CoreBackgroundJobPerformer.cs). - The global
AutomaticRetryfilter turns that failure into a scheduled retry, 10 times by default, with delays that add up to more than four hours. The schedule is in Hangfire automatic retry. - Every retry fails the same way. After the last one, the worker unwraps the wrapper and stores a Failed state whose exception is the original
ObjectDisposedExceptionand whose reason is the wrapper's message (Worker.cs). - The job stays on the dashboard's Failed page, which shows the stored exception type, message, and details with the stack trace (FailedState.cs, FailedJobsPage.cshtml). Hangfire's docs say a failed job "will not be expired unless you delete it explicitly".
A recurring job repeats this at every occurrence, each with its own retry chain; Hangfire automatic retry covers how those chains overlap. Requeueing one from the dashboard doesn't help until the code is fixed and deployed.
Unawaited work doesn't follow this pattern, as described above: a discarded task leaves the run Succeeded, with nothing retried and nothing on the Failed page, and an exception from an async void lambda isn't stored in any task and can crash the process.
| What you see | Most likely cause | Fix |
|---|---|---|
Every attempt fails with Object name: 'IServiceProvider' |
The job resolves services from a provider captured during a request | Instance method with constructor injection |
| Every attempt fails with "Cannot access a disposed context instance" | Your code disposes the injected context, or uses one from another scope | Remove the using; let the scope dispose it |
| The job shows Succeeded but the work is missing, and no exception was recorded | A discarded or Task.Run task failed after the scope was disposed |
Await every task the job starts |
| The process hosting the Hangfire server crashes | An async void lambda, such as one passed to ForEach, threw |
A foreach loop with await instead of ForEach |
| "A second operation was started on this context instance" | A singleton or parallel queries share one context | Scoped or unregistered job class; a factory for parallel work |
Patterns That Work#
Inject Scoped Services Into the Job Class#
Put the work in an instance method, take the DbContext and other services in the constructor, and register the recurring job with the generic AddOrUpdate<T> so Hangfire stores the job type:
using Hangfire;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("App")));
builder.Services.AddHangfire(config => config
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();
var app = builder.Build();
app.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<InvoiceReminderJob>(
"invoice-reminders",
job => job.RunAsync(CancellationToken.None), // replaced at run time; canceled on shutdown or a job state change
"0 9 * * *");
app.Run();
public sealed class InvoiceReminderJob(AppDbContext db, ILogger<InvoiceReminderJob> logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
var overdue = await db.Invoices
.Where(invoice => invoice.DueAtUtc < DateTime.UtcNow && !invoice.ReminderSent)
.ToListAsync(cancellationToken);
foreach (var invoice in overdue)
{
invoice.ReminderSent = true;
}
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Marked {Count} overdue invoices for reminders.", overdue.Count);
}
}AppDbContext stands for your EF Core context with an Invoices set. InvoiceReminderJob isn't registered, so the activator constructs it for each run and gives it that run's AppDbContext. The method returns a Task, so the scope stays open until SaveChangesAsync completes. The schedule is evaluated in UTC; Hangfire cron expressions and time zones covers the alternatives. The packages are the same ones that post installs, plus your EF Core provider.
Use a DbContext Factory for Parallel or Batched Work#
When one run needs several contexts at once, or a fresh change tracker per batch, inject a factory instead of a context. AddDbContextFactory registers IDbContextFactory<T>, and each context you create from it is yours to dispose:
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("App")));public sealed class RegionalTotalsJob(IDbContextFactory<AppDbContext> contexts)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
string[] regions = ["eu", "us", "apac"];
await Task.WhenAll(regions.Select(async region =>
{
await using var db = contexts.CreateDbContext(); // one context per parallel branch
var total = await db.Orders
.Where(order => order.Region == region)
.SumAsync(order => order.Amount, cancellationToken);
// ... store total
}));
}
}Here the job owns each context, so disposing it with await using is correct, unlike an injected one. AddDbContextFactory also registers the context type itself as a scoped service, as its source comment and registration code show (EntityFrameworkServiceCollectionExtensions.cs, registration), so jobs that take AppDbContext directly keep working.
Keep Arguments Small and Re-read State in the Job#
Enqueue with IDs, load current rows inside the job, and design the work so that running it twice is harmless, since retries and restarts repeat it. Idempotent scheduled jobs covers that design, and Hangfire automatic retry covers which exceptions are worth retrying at all. ObjectDisposedException from wiring never is: if one keeps a long retry chain alive, [AutomaticRetry(Attempts = 0)] on that job sends it to Failed on the first attempt while you fix the code.
See the Failure From Outside Hangfire With CronRadar#
The dashboard's Failed page shows the exception, but only to someone who opens it, and a run that never starts leaves nothing there. CronRadar watches each recurring job's schedule from outside the process. The CronRadar.Hangfire package discovers recurring jobs and reports their runs:
dotnet add package CronRadar.Hangfireusing CronRadar.Hangfire;
// Alongside builder.Services.AddHangfire(...) and AddHangfireServer().
builder.Services.AddCronRadarHangfire();Set CRONRADAR_API_KEY in the host's environment or secret store rather than in a committed file. Package status, checked October 30, 2026: NuGet installs CronRadar.Hangfire 0.1.0, which can report a failure before Hangfire finishes retrying, including cancellations; reporting only after retries are exhausted requires the pending 0.2.1 release. In 0.1.0, from its source at the release tag:
AddCronRadarHangfire()adds a global Hangfire server filter, runs a registry reconcile at startup, and registers a recurring job,cronradar:reconcile, that repeats it every 15 minutes by default. That job's class isn't registered in DI, soAspNetCoreJobActivatorconstructs it through the sameGetServiceOrCreateInstancefallback described above.- For each run of a recurring job, the filter reports
startbefore the method runs. Afterwards it reportscompleteif the run threw nothing and wasn't canceled, andfailotherwise. One-time background jobs are skipped, and[SkipMonitor]on a method opts it out.
For the errors in this guide, that means:
- A recurring job's wiring error alerts on the first attempt. Because 0.1.0 reports every failed attempt of a recurring job, the first
ObjectDisposedExceptionis afail, and CronRadar recordsfailedand opens an incident immediately (concepts), hours before Hangfire's retry chain ends. Each later retry reportsfailagain. One-time jobs, such as the staticBackgroundJob.Enqueueexample above, aren't reported at all. - The message is Hangfire's wrapper. The filter sends the message of the exception Hangfire gives server filters, which is the
JobPerformanceException("An exception occurred during performance of the job.") rather than theObjectDisposedExceptioninside it (BackgroundJobPerformer.cs). Open the job on Hangfire's Failed page for the real exception. - A discarded task reports success. The method returned normally, so 0.1.0 reports
complete. 0.1.0 has no way to report outcome values, so CronRadar can't tell that the run skipped its work. - A run that never happens still alerts. If no run reports by the expected time, the monitor goes from
healthytowarningduring the grace period and tocriticalafter it, for example because the Hangfire server is down.
Alerts go to email, Slack, a webhook, Discord, or PagerDuty. Jobs with noisy transient failures will alert on attempts that later succeed until 0.2.1 ships; Hangfire automatic retry covers how retry settings change that, and Hangfire monitoring covers what to check inside Hangfire itself.
Frequently Asked Questions#
Does Hangfire create a new DbContext for each job?#
Yes, if the context is registered as scoped, which AddDbContext does by default. Hangfire.AspNetCore creates a DI scope for every execution, resolves the job class from it, and disposes it when the method or its returned task finishes. Each retry and each recurring occurrence gets its own scope and its own context.
Why does my Hangfire job throw "Cannot access a disposed object"?#
The job reached a service that belongs to a scope that was already disposed. Usually that is a provider or service captured during an HTTP request and reached later from a static method or a singleton. Otherwise, the job disposed its own injected DbContext, or started work it didn't await that ran after the job's scope was disposed. Hangfire's own scope is always fresh, so look for code that reaches outside it or outlives it.
Can I inject a scoped service into a Hangfire job?#
Yes. Take it in the job class's constructor and enqueue or register the job with the generic overload, such as AddOrUpdate<InvoiceReminderJob>(...). The class doesn't need to be registered; if it is, register it as scoped or transient, because a singleton job keeps one context for the life of the process.
Can I pass a DbContext or another service as a Hangfire job argument?#
No. Arguments are serialized to JSON when the job is created, so the job never receives the live object, and delegate parameters such as a factory function are rejected. Pass IDs and plain values, and let the job resolve services from its own scope.
Why does the error happen on every retry?#
Each retry gets a new scope, but the cause is in the code: a static field still points at the same disposed provider, or the same disposing code runs again. All 10 default retries fail the same way, and the job ends on the Failed page with the original ObjectDisposedException. Fix the code and deploy; requeueing won't help.
Put this guide to work
Connect your Hangfire job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Hangfire 0.1.0. Setup and discovery work, but this release can report failures before Hangfire finishes retrying, including cancellations. Reporting only after retries are exhausted requires the pending 0.2.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.