# Hangfire Dependency Injection: Scoped Services, DbContext, and Disposed Objects

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/hangfire-dependency-injection

> How Hangfire.AspNetCore 1.8 gives each job its own DI scope, why jobs throw "Cannot access a disposed object," and safe DbContext patterns for recurring jobs.

**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](https://www.nuget.org/packages/Hangfire.AspNetCore/1.8.25) (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](https://cronradar.com/blog/hangfire-automatic-retry), jobs that never start in [why a Hangfire recurring job is not running](https://cronradar.com/blog/hangfire-recurring-job-not-running), and scoped services in a plain hosted service in [.NET BackgroundService scheduled tasks](https://cronradar.com/blog/dotnet-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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/HangfireServiceCollectionExtensions.cs#L82-L103)). `AddHangfireServer` hands that activator to the server unless `BackgroundJobServerOptions.Activator` is already set ([same file](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/HangfireServiceCollectionExtensions.cs#L243-L248)). The activator lives in the Hangfire.NetCore package, which Hangfire.AspNetCore references.

What happens for each run, from the source:

1. **A new scope per execution.** `CoreBackgroundJobPerformer.Perform` opens `using (var scope = _activator.BeginScope(context))` around the whole run ([CoreBackgroundJobPerformer.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L47-L73)), and `BeginScope` returns a wrapper around `_serviceScopeFactory.CreateScope()` ([AspNetCoreJobActivator.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/AspNetCore/AspNetCoreJobActivator.cs#L32-L42)).
2. **The job type is resolved from that scope.** `Resolve` calls `ActivatorUtilities.GetServiceOrCreateInstance(_serviceScope.ServiceProvider, type)` ([AspNetCoreJobActivatorScope.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/AspNetCore/AspNetCoreJobActivatorScope.cs#L32-L35)). That method returns the registered service, and ["if one is not found then instantiate it directly"](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.activatorutilities.getserviceorcreateinstance), filling the constructor from the same scope. A job class doesn't have to be registered.
3. **Static methods get no instance.** `Perform` calls `Resolve` only when the job method isn't static. A static job method gets nothing from the container.
4. **Async jobs keep the scope until their task completes.** For a method that returns `Task`, `InvokeMethod` blocks until the task finishes before `Perform` returns ([CoreBackgroundJobPerformer.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L154-L215)).
5. **The scope is then disposed.** `DisposeScope` calls `DisposeAsync` when the scope supports it and `Dispose` otherwise ([AspNetCoreJobActivatorScope.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/AspNetCore/AspNetCoreJobActivatorScope.cs#L37-L52)), which disposes every scoped and transient disposable resolved during the run, including the `DbContext`.

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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L420-L462)). 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:

```csharp
// 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 every `GetService` call and throws with the object name `IServiceProvider` ([ServiceProviderEngineScope.cs](https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ServiceProviderEngineScope.cs#L43-L48), [ThrowHelper.cs](https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ThrowHelper.cs), [message text](https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx#L3173-L3178)).
- **`System.ObjectDisposedException: Cannot access a disposed context instance.`** Something used a `DbContext` after it was disposed, either by its scope or by your own code. EF Core throws this from `CheckDisposed` with your context's class name as the object name ([DbContext.cs](https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/DbContext.cs#L488-L496), [CoreStrings.resx](https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Properties/CoreStrings.resx#L369-L371)).

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`](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-9.0), and ["in apps that process requests, scoped services are disposed at the end of the request"](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes). 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](https://docs.hangfire.io/en/latest/background-methods/passing-dependencies.html) 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:

```csharp
// 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](#inject-scoped-services-into-the-job-class).

### 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L540-L560)) and [serializes it to JSON with Newtonsoft.Json](https://docs.hangfire.io/en/latest/background-methods/passing-arguments.html) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L530-L535)), 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L30-L36), [SubstituteArguments](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L257-L279)). The `CancellationToken` it passes is linked to both the server's shutdown and the job's abort ([ServerJobCancellationToken.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/ServerJobCancellationToken.cs#L223-L234)), and Hangfire's [Using Cancellation Tokens](https://docs.hangfire.io/en/latest/background-methods/using-cancellation-tokens.html) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L497-L501)). It can't see async void code inside the method:

```csharp
// 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`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1.foreach) takes an `Action<T>`, and Microsoft's docs warn that ["an async method that returns void can't be awaited"](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types) 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 `WriteAsync` and `Task.Run` calls above. When a method that returns a task throws, ["the exception is stored in the returned task"](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types) 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 when `Perform` throws ([Worker.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L336-L352)). Nothing retries, nothing reaches the Failed page, and nothing records the exception unless your code logs it.
- **An `async void` lambda**, like the one passed to `ForEach`. It has no task to hold an exception. .NET posts the exception to the `SynchronizationContext` that 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](https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncVoidMethodBuilder.cs#L123-L142)). Microsoft's docs say such exceptions ["are likely to cause your application to fail"](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types), and an unhandled exception in a thread [causes the application to terminate in most cases](https://learn.microsoft.com/en-us/dotnet/standard/threading/exceptions-in-managed-threads). An `ObjectDisposedException` from 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"](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes). 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"](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes). 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](https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Properties/CoreStrings.resx#L336-L338)), because a [`DbContext` is not thread-safe](https://learn.microsoft.com/en-us/ef/core/dbcontext-configuration/). 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"](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes) ([scope validation](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/overview#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:

1. The first attempt throws `ObjectDisposedException`. Hangfire wraps it in `JobPerformanceException` ("An exception occurred during performance of the job.") on the way out of the performer ([CoreBackgroundJobPerformer.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L76-L108)).
2. The global `AutomaticRetry` filter 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](https://cronradar.com/blog/hangfire-automatic-retry).
3. 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 `ObjectDisposedException` and whose reason is the wrapper's message ([Worker.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L336-L352)).
4. The job stays on the dashboard's Failed page, which shows the stored exception type, message, and details with the stack trace ([FailedState.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/States/FailedState.cs#L195-L203), [FailedJobsPage.cshtml](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Pages/FailedJobsPage.cshtml#L137-L145)). Hangfire's docs say a failed job ["will not be expired unless you delete it explicitly"](https://docs.hangfire.io/en/latest/background-processing/dealing-with-exceptions.html).

A recurring job repeats this at every occurrence, each with its own retry chain; [Hangfire automatic retry](https://cronradar.com/blog/hangfire-automatic-retry#each-occurrence-gets-its-own-retry-budget) 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](#work-that-outlives-the-job-method): 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:

```csharp
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](https://cronradar.com/blog/hangfire-cron-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`](https://learn.microsoft.com/en-us/ef/core/dbcontext-configuration/) registers `IDbContextFactory<T>`, and each context you create from it is yours to dispose:

```csharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("App")));
```

```csharp
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](https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Extensions/EntityFrameworkServiceCollectionExtensions.cs#L636-L638), [registration](https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Extensions/EntityFrameworkServiceCollectionExtensions.cs#L919-L925)), 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](https://cronradar.com/blog/idempotent-scheduled-jobs) covers that design, and [Hangfire automatic retry](https://cronradar.com/blog/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:

```bash
dotnet add package CronRadar.Hangfire
```

```csharp
using 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](https://github.com/cronradar/cronradar-hangfire/blob/v0.1.0/src/CronRadar.Hangfire/CronRadarServiceCollectionExtensions.cs):

- `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, so `AspNetCoreJobActivator` constructs it through the same `GetServiceOrCreateInstance` fallback described above.
- For each run of a recurring job, the [filter](https://github.com/cronradar/cronradar-hangfire/blob/v0.1.0/src/CronRadar.Hangfire/CronRadarExtensions.cs) reports `start` before the method runs. Afterwards it reports `complete` if the run threw nothing and wasn't canceled, and `fail` otherwise. 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 `ObjectDisposedException` is a `fail`, and CronRadar records `failed` and opens an incident immediately ([concepts](https://docs.cronradar.com/concepts)), hours before Hangfire's retry chain ends. Each later retry reports `fail` again. One-time jobs, such as the static `BackgroundJob.Enqueue` example 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 the `ObjectDisposedException` inside it ([BackgroundJobPerformer.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/BackgroundJobPerformer.cs#L160-L181)). 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 `healthy` to `warning` during the grace period and to `critical` after 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](https://cronradar.com/blog/hangfire-automatic-retry#monitor-hangfire-retries-and-missed-runs-with-cronradar) covers how retry settings change that, and [Hangfire monitoring](https://cronradar.com/blog/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.
