Mert Özen Further With Every Line
Backend Background Jobs and Scheduled Jobs — #11

Version Migrations for Background Jobs: BackgroundService Changes from .NET 6 to 10, .NET 11 and Quartz 3→4

Mert Özen Nov 8, 2026 10 min 2 views
Version Migrations for Background Jobs: BackgroundService Changes from .NET 6 to 10, .NET 11 and Quartz 3→4

.NET 6 started stopping the host on a BackgroundService failure, ShutdownTimeout went from 5 to 30 seconds in .NET 7, .NET 8 brought lifecycle hooks, and .NET 10 moved all of ExecuteAsync to the background: an exception before the first await crashes the process with code 134 on .NET 8 but is logged and exits with 0 on .NET 10. In Quartz 3→4 a simple job gave a single CS0535 error; the real work is packages, the schema migration and changed defaults.

Version note: The code in this post was built and run with .NET 10; the .NET 8 comparisons ran on the .NET 8.0.25 runtime, and the Quartz examples with Quartz.NET 3.22.9 and 4.7.2. The .NET 11 items are based on the Microsoft Learn breaking change document as of the time of writing (the RC (Release Candidate) period).

Guess First

Bookstore has a service left over from the .NET Core 3.1 days: an IHostedService, registered with AddHostedService in Startup.cs, with a System.Threading.Timer inside, that cleans up carts every second. The project is moving to .NET 10. Does the code compile? If one cleanup run takes 2.5 seconds, do runs overlap? And on the Quartz side: how many errors do you get when you upgrade a job written for 3.x to package 4.7.2 and compile it?

Timeline: From .NET 6 to .NET 11

The hosted service infrastructure changed a little in every release. Most of the changes are behavioral: the code keeps compiling but runs differently. That's why the compiler doesn't tell you.

VersionChangeEffect
.NET 6BackgroundServiceExceptionBehavior, default StopHostAn unhandled error in ExecuteAsync is logged and the host stops
.NET 7ShutdownTimeout default from 5 s to 30 sWork gets more time at shutdown
.NET 8IHostedLifecycleService, ServicesStartConcurrently, ServicesStopConcurrently, StartupTimeoutExtra hooks around startup and shutdown; optional parallel startup
.NET 10All of ExecuteAsync in the backgroundCode before the first await no longer blocks startup
.NET 11RunAsync/StopAsync throw on a crashed BackgroundServiceThe process exits with a non-zero code

According to the breaking change note on Microsoft Learn, the .NET 11 change first arrived in Preview 3 and comes with the GA (General Availability) release on 10 November 2026. Let's go through the other rows one by one, with real output where possible.

.NET 6 and .NET 7: The Lost Error and the 5-Second Shutdown

Before .NET 6, an exception escaping a BackgroundService's ExecuteAsync was lost: the service silently stopped while the host kept running. The .NET 6 breaking change note on Microsoft Learn says the service then appeared unresponsive. With .NET 6 the exception is logged and, by default, the host stops. In the second post of the series we saw the real output of this on .NET 10: the error was logged, the host stopped, and the process exited with 0. Code that expects the old behavior, i.e. the assumption that "the API (Application Programming Interface) keeps living even if the background work crashes", starts taking down the whole app after moving to .NET 6. Ignore brings the old behavior back, but the right fix is protecting each run with its own try/catch.

Let's also correct something that often shows up in plans and blog posts: ShutdownTimeout becoming 30 seconds came with .NET 7, not .NET 6. In the dotnet/runtime repository, HostOptions.cs has TimeSpan.FromSeconds(5) at the v6.0.0 tag and TimeSpan.FromSeconds(30) at the v7.0.0 tag. Work that a service on .NET 6 had cut off after 5 seconds at shutdown can wait 30 seconds on .NET 7 and later. That's usually good news; but since Kubernetes' terminationGracePeriodSeconds also defaults to 30 seconds, the budget collision we discussed in the second post also started with .NET 7.

From .NET 8 to .NET 10: The Synchronous Part of ExecuteAsync

We measured the .NET 10 change's effect on startup time in the second post. It has one more effect, and it's more dangerous during a migration: an exception thrown before the first await. A service that checks configuration and throws if it's missing:

public sealed class ConfigCheckingService(IConfiguration config, ILogger<ConfigCheckingService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // before the first await: runs inside StartAsync on .NET 8, in the background on .NET 10
        string endpoint = config["Supplier:Endpoint"]
            ?? throw new InvalidOperationException("Supplier:Endpoint is not configured");
        logger.LogInformation("Syncing from {Endpoint}", endpoint);
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }
}

I ran the same project with both targets, without Supplier:Endpoint in configuration:

## net8.0
05:31:53.339 fail: Microsoft.Extensions.Hosting.Internal.Host[11] Hosting failed to start System.InvalidOperationException: Supplier:Endpoint is not configured [...]
Unhandled exception. System.InvalidOperationException: Supplier:Endpoint is not configured
   at Bookstore.Jobs.ConfigCheckingService.ExecuteAsync(CancellationToken stoppingToken) in /app/p02/Services.cs:line 56
   at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__15_1(IHostedService service, CancellationToken token)
   [...]
exit code 134

## net10.0
05:31:53.644 info: Microsoft.Hosting.Lifetime[14] Now listening on: http://127.0.0.1:17110
05:31:53.642 fail: Microsoft.Extensions.Hosting.Internal.Host[9] BackgroundService failed System.InvalidOperationException: Supplier:Endpoint is not configured [...]
05:31:53.654 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:31:53.654 crit: Microsoft.Extensions.Hosting.Internal.Host[10] The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. [...]
05:31:53.654 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
05:31:53.658 RunAsync returned, exit code 0
exit code 0

On .NET 8 the exception escaped from inside StartAsync; the host never started and the process crashed with an unhandled exception and code 134 (SIGABRT (abort signal)). On .NET 10 the same exception was caught in the background and logged as "BackgroundService failed", Kestrel even started listening for a moment, then the StopHost behavior stopped the host and the process exited with 0.

In practice this means: behavior you relied on in .NET 8, "with missing configuration the pod never comes up and goes into CrashLoopBackOff", becomes "the pod comes up and shuts itself down with a success code" on .NET 10. Your orchestrator's restart policy, your deployment's readiness check and your alert rules may not see the difference. Do configuration that must be validated at startup not inside the BackgroundService, but in options validation (ValidateOnStart) or in StartAsync. With .NET 11, the process exiting with a non-zero code in this scenario will close part of the gap.

Reading Old Code: IHostedService and a Timer

A pattern you'll often meet in old docs and projects:

// the "IHostedService + Timer" pattern from old docs
public class CartCleanupService : IHostedService, IDisposable
{
    private readonly ILogger<CartCleanupService> _logger;
    private Timer? _timer;
    private int _runs;

    public CartCleanupService(ILogger<CartCleanupService> logger) => _logger = logger;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
        return Task.CompletedTask;
    }

    private void DoWork(object? state)
    {
        int run = Interlocked.Increment(ref _runs);
        _logger.LogInformation("Cleanup run {Run} started on thread {Thread}", run, Environment.CurrentManagedThreadId);
        Thread.Sleep(2500); // a run takes longer than the period
        _logger.LogInformation("Cleanup run {Run} finished", run);
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Change(Timeout.Infinite, 0);
        return Task.CompletedTask;
    }

    public void Dispose() => _timer?.Dispose();
}

It compiles and runs on .NET 10. But its output shows why the pattern was abandoned:

05:32:16.840 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 1 started on thread 8
05:32:16.852 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:32:17.840 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 2 started on thread 10
05:32:18.840 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 3 started on thread 11
05:32:19.354 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 1 finished
05:32:19.841 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 4 started on thread 8
05:32:20.342 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 2 finished
05:32:20.831 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...

Two problems show up. First, runs overlap: each run takes 2.5 seconds, while the Timer starts a new run on another thread every second without waiting for the previous one to finish; at 18.840 four runs were going at once. The hosted services page on Microsoft Learn says so plainly: the Timer doesn't wait for previous executions of DoWork to finish. Second, shutdown: StopAsync only stops the timer and doesn't wait for running runs; runs 3 and 4 never finished. Since the callback isn't async, there's no cancellation token either.

The modern equivalent is the BackgroundService + PeriodicTimer from the third post: a single loop, runs never overlap, at most one tick arrives right away after a long run, and stoppingToken is carried into the work. When moving an old service, decide on the behavior first: was running in parallel really wanted, or had it just gone unnoticed? Usually the latter.

AddHostedService in Startup.cs

The skeleton of old code often looks like this:

// the skeleton of the .NET Core 3.1 / .NET 5 era: Program + Startup
Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(web => web.UseStartup<Startup>())
    .Build()
    .Run();

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddHostedService<CartCleanupService>();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(e => e.MapGet("/", () => "ok"));
    }
}

This skeleton also compiles and runs on .NET 10; Host.CreateDefaultBuilder and ConfigureWebHostDefaults are still supported. Moving it isn't mandatory, but when you switch to the new hosting model (WebApplication.CreateBuilder), hosted service registrations carry over one to one: the line services.AddHostedService<T>() becomes builder.Services.AddHostedService<T>(). Remember that registration order decides startup order, and its reverse decides shutdown order; keep the order from ConfigureServices. The difference between the two hosting models is in the ASP.NET Core Web API series post "Program.cs and the Hosting Model in ASP.NET Core: WebApplication and Environments".

From Quartz 3 to 4

Quartz.NET 4.0 is a major release and its migration guide is long. A real experiment first: a typical job written with 3.22.9, using the Quartz.Extensions.Hosting package:

// Quartz.Extensions.Hosting 3.22.9
builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("stock-report");
    q.AddJob<StockReportJob>(o => o.WithIdentity(jobKey));
    q.AddTrigger(t => t.ForJob(jobKey).WithCronSchedule("0/2 * * * * ?"));
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);

public class StockReportJob(ILogger<StockReportJob> logger) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        await Task.Delay(100, context.CancellationToken);
        logger.LogInformation("Stock report generated by Quartz {Version}", typeof(IJob).Assembly.GetName().Version);
    }
}
      Stock report generated by Quartz 3.22.9.0
      Stock report generated by Quartz 3.22.9.0

I switched the package from Quartz.Extensions.Hosting 3.22.9 to Quartz 4.7.2 and compiled:

$ dotnet build
StockReportJob.cs(5,63): error CS0535: 'StockReportJob' does not implement interface member 'IJob.Execute(IJobExecutionContext, CancellationToken)'

A single error. The AddQuartz, AddJob, AddTrigger and AddQuartzHostedService calls compiled unchanged. To fix the error I brought the signature in line with 4.x:

public sealed class StockReportJob(ILogger<StockReportJob> logger) : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        await Task.Delay(100, cancellationToken);
        logger.LogInformation("Stock report generated by Quartz {Version}", typeof(IJob).Assembly.GetName().Version);
    }
}
      Stock report generated by Quartz 4.7.2.0

That it looks this easy is misleading; the changes the compiler doesn't show matter more. The most critical ones from the migration guide:

  • Packages: Quartz.Extensions.DependencyInjection, Quartz.Extensions.Hosting and Quartz.Serialization.SystemTextJson moved into the core Quartz package; empty 4.x packages exist under the old IDs but should be removed. Keeping the 3.x packages gives CS0433 errors for types such as QuartzOptions.
  • Schema: schema_30_to_40_upgrade_<database>.sql is mandatory and can be applied while 3.x nodes are running; the index script after the last 3.x node stops. A 4.0 node refuses to start against an unmigrated schema and reports the missing column.
  • Defaults: quartz.config is no longer read; the default InstanceName changed from DefaultQuartzScheduler to QuartzScheduler, and since persistent store rows are keyed on this name, an app that never set a name explicitly can't see its old jobs. The misfire threshold of an unconfigured in-memory store dropped from 60 seconds to 5.
  • Serialization: quartz.serializer.type = json now means System.Text.Json (it was Newtonsoft in 3.x); binary serialization is gone and 4.x can't read old binary blobs, so they must be converted while still on 3.x.
  • API: StdSchedulerFactory is gone, Quartz.Spi became Quartz.Extensibility, and JobDataMap's indexer throws KeyNotFoundException for a missing key instead of returning null.
  • Ecosystem: MassTransit.Quartz doesn't run on 4.x, and the guide says to stay on 3.x; the OpenTelemetry community instrumentation written for Quartz 3 produces no spans on 4.x (the tenth post).

Moving the cancellation token into a parameter has a side benefit. A job that doesn't pass the token to a lower call produced no warning with the default analysis settings; compiling with AnalysisLevel=latest-recommended brought CA2016:

public sealed class IgnoringJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        await Task.Delay(100);
    }
}

$ dotnet build --no-incremental -p:AnalysisLevel=latest-recommended
Probe.cs(7,15): warning CA2016: Forward the 'cancellationToken' parameter to the 'Delay' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)

In 3.x the token lived in context.CancellationToken and CA2016 couldn't see it; as a parameter, the rule works. So raising the analysis level during the migration is a cheap way to find jobs that ignore the token at shutdown.

In Production

Upgrade a Quartz cluster in order: the schema migration first (while 3.x nodes run), then the nodes, and the index script last. The guide's "Upgrading a running deployment" section describes this order; since 4.x's minor releases (4.6 and 4.7) also bring their own mandatory migrations, check the schema step on every minor upgrade.

Test behavior differences in a .NET upgrade, don't rely on compilation. The timing of ExecuteAsync, exception behavior and shutdown time are changes the compiler can't see. A short integration test for each service that throws at startup, ignores the token at shutdown, or runs long is the cheapest insurance for the migration.

Know your target framework. Quartz 4 targets only net10.0; a service still running on .NET 8 or .NET Framework has to stay on 3.x. Since .NET 8 support ends on 10 November 2026, moving those services to .NET 10 should already be on the agenda.

Common Pitfalls

Assuming that, after moving to .NET 10, an exception thrown at startup still takes the pod down. It's now logged and the process exits with 0; move validation to ValidateOnStart or StartAsync.

Carrying the old Timer pattern over as is. Overlapping runs and an unexpected shutdown are the same in the new version.

Relying on Quartz's default InstanceName. Since the name changed, 4.x may not see the rows 3.x wrote; set the name explicitly.

Leaving 3.x packages next to 4.x. CS0433 errors or, worse, an integration stuck on an old package.

Upgrading an app that uses MassTransit.Quartz to 4.x. According to the guide, it doesn't work.

A Small Experiment

★☆☆ Rewrite CartCleanupService with BackgroundService + PeriodicTimer and run it with the same 2.5-second run. How many runs do you see in the log, and is a run left unfinished at shutdown?

★★☆ Move the validation in ConfigCheckingService to AddOptions<SupplierOptions>().ValidateDataAnnotations().ValidateOnStart() and run the app on net8.0 and net10.0. What is the process exit code in each?

★★★ Set up a cluster with Quartz 3.22.9 on a PostgreSQL database, create a few jobs and triggers, then apply the 4.0 migration script and start a 4.7.2 node. Do the old triggers carry on? What do you see if you hadn't set InstanceName explicitly?

This post ends the Background Jobs and Scheduled Jobs series. We started from why Task.Run gets lost and went through the BackgroundService lifecycle, cron and time zones, Hangfire and Quartz, retries and idempotency, locks across instances, long jobs and visibility. The shared lesson fits in one sentence: design a background job as if it can be restarted at any moment, run twice, and stop silently. Next in the Backend category, the Working with Message Queues series takes up the side where work flows to other services as messages.