Skip to main content
ANVISoftware Solutions
Lesson 12 of 12Advanced20 min

Application Lifecycle

By the end of this lesson

Hook into startup and shutdown, and shut down gracefully.

A process starts, does work, and stops. The interesting part is the stop, because it is the only phase that happens on someone else's schedule. Your application does not decide when a deployment replaces it, when an orchestrator moves it to another node, or when an operator restarts it.

Graceful shutdown is the behaviour of stopping well when asked: stop accepting new work, let work already in progress finish, release what you hold, and exit. The opposite is being killed mid-operation, which turns a routine deployment into a small pile of half-finished work.

The host implements the sequence. What you have to do is honour it — which in practice means forwarding cancellation tokens and keeping shutdown work short.

The full sequence, from process start to exit:

  1. Build

    Configuration is layered, logging is created, registrations are collected, and the container is built. Nothing of yours is running yet. A failure here — a validated options class with a bad value, for instance — stops the application before it can serve anything, which is the outcome you want.

  2. Start

    The host calls StartAsync on each hosted service in registration order, awaiting each one. Background services reach their first await and the host moves on. When all of them have started, the ApplicationStarted notification fires.

  3. Run

    The host waits. Your workers loop, your server handles requests, and the process stays alive because the host is holding it open — not because anything is spinning.

  4. Stop requested

    A SIGTERM from a container runtime, Ctrl+C in a terminal, or a call to StopApplication in your own code. All three arrive at the same place. The ApplicationStopping notification fires, and the cancellation token every hosted service was given is signalled.

  5. Drain

    New work is refused while work in progress is given time to finish. A web server stops accepting connections and completes the requests it has. Workers see their token cancelled and are expected to finish the current unit of work. The host allows a fixed period for this, and the default is 30 seconds.

  6. Stop and dispose

    StopAsync is called on each hosted service in reverse registration order. ApplicationStopped fires, the host is disposed, and disposal of the container disposes every singleton that implements IDisposable. Then the process exits.

Lifetime notifications, and a job that stops itself when it is done
C#
public sealed class EmployeeImportJob(
    IHostApplicationLifetime lifetime,
    IServiceScopeFactory scopeFactory,
    ILogger<EmployeeImportJob> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        lifetime.ApplicationStopping.Register(
            () => logger.LogInformation("Stop requested; finishing current batch"));

        try
        {
            using IServiceScope scope = scopeFactory.CreateScope();
            var service = scope.ServiceProvider.GetRequiredService<EmployeeService>();

            int imported = await service.ImportPendingAsync(stoppingToken);
            logger.LogInformation("Imported {EmployeeCount} employees", imported);
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("Import interrupted by shutdown; remaining records will be retried");
        }
        finally
        {
            // The work is finished, so ask the host to shut down
            lifetime.StopApplication();
        }
    }
}
  • ApplicationStopping fires as soon as a stop is requested, before the drain period. Registering a callback there is how you log the transition or flip a health endpoint to unhealthy, and it must return quickly because the shutdown clock is already running.
  • OperationCanceledException during shutdown is not a failure. Catching it separately and logging at Warning keeps it out of your error rate, which matters because every deployment would otherwise produce errors.
  • StopApplication requests shutdown from inside the application. This is the shape of a job: do the work, then ask the host to stop, and the process exits with a success code. Without it, a job-style application finishes its work and then sits there indefinitely.
  • The comment on the finally block is worth reading twice: StopApplication requests a stop, it does not perform one. The host still runs the full shutdown sequence, which is what makes this safe.
Configuring the drain period, and a loop that respects it
C#
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.Configure<HostOptions>(options =>
{
    // How long the host waits for in-flight work during shutdown.
    // The default is 30 seconds. Match it to your longest reasonable unit of work.
    options.ShutdownTimeout = TimeSpan.FromSeconds(25);
});

builder.Services.AddHostedService<LeaveRequestConsumer>();

// A consumer that finishes the message it is holding, then stops
public sealed class LeaveRequestConsumer(
    IMessageQueue queue,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            LeaveRequestMessage? message = await queue.ReceiveAsync(stoppingToken);
            if (message is null)
            {
                continue;
            }

            using IServiceScope scope = scopeFactory.CreateScope();
            var service = scope.ServiceProvider.GetRequiredService<EmployeeService>();

            // Deliberately not passing stoppingToken: once started, this finishes
            await service.ApplyLeaveRequestAsync(message, CancellationToken.None);
            await queue.AcknowledgeAsync(message, CancellationToken.None);
        }
    }
}
  • ShutdownTimeout is the host's patience. When it expires, the host stops waiting and the process exits with work still in progress. Longer is not automatically better: it delays every deployment and can exceed the grace period your platform allows.
  • The token is passed to ReceiveAsync, so waiting for a new message is interrupted immediately on shutdown. That is the part you want cancelled promptly — waiting for work is not work.
  • It is deliberately not passed to ApplyLeaveRequestAsync. Once a message is being processed, cancelling halfway through risks a partial change with no acknowledgement. Finishing takes a bounded amount of time and leaves the system consistent.
  • That distinction is the whole craft of graceful shutdown: cancel the waiting, finish the working. Forwarding the token everywhere is not the goal, and neither is ignoring it.
  • The cost of finishing is that your unit of work must be shorter than the shutdown timeout. If a message can take two minutes, either shorten the unit of work or accept that deployments will interrupt it.

The same replacement, with and without a graceful stop:

 Killed abruptlyStopped gracefully
Requests in flightConnections dropped. Callers see a reset, and a retry may repeat a side effectCompleted and responded to before the process exits
Message being processedNever acknowledged, so it is redelivered and the work happens twiceFinished and acknowledged, so it is processed once
Database workAn open transaction is rolled back by the server after a timeout, holding locks in the meantimeCommitted or rolled back deliberately, connections returned to the pool
Buffered telemetry and logsLost, including the lines that would explain what was happeningFlushed during shutdown
What the deployment looks likeA brief spike of errors on every release, which teams learn to ignoreNo error spike, so a real spike means something

This matters most during a rolling deployment, which is how most services are replaced. The platform starts a new instance, waits for it to report healthy, then asks an old instance to stop. That request is a SIGTERM signal to the container, and the host translates it into the sequence above.

The platform does not wait indefinitely. It allows a termination grace period — 30 seconds by default on Kubernetes — and then sends SIGKILL, which cannot be caught, handled or delayed. Whatever the process was doing stops mid-instruction.

The number that bites is the relationship between the two. If your ShutdownTimeout is longer than the platform's grace period, your careful drain is cut short by a kill you did not plan for, and the graceful path is never actually exercised. Keep the application's timeout comfortably below the platform's, and set the platform's from how long your work genuinely takes.

There is a second gap worth knowing about, because it produces errors that look like a shutdown bug and are not. A load balancer needs time to notice an instance is going away. If the process exits the instant it receives SIGTERM, traffic is still being routed to it for a few seconds and those requests fail. The usual answer is to fail readiness immediately on ApplicationStopping and then keep serving for a short, deliberate period before draining.

Summary

  • Start runs hosted services in registration order; stop runs them in reverse, then the host and its singletons are disposed
  • SIGTERM, Ctrl+C and StopApplication all arrive at the same place: the stopping notification and a cancelled token
  • Graceful shutdown means refusing new work and letting in-flight work finish within a bounded period — 30 seconds by default
  • Cancel the waiting and finish the working: forward the token to waits, not necessarily to work already started
  • Keep ShutdownTimeout below the platform's termination grace period, and because shutdown code is never guaranteed to run, keep the work safe to retry

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Make a worker miss the signal, then fix it

Write a background service whose loop awaits Task.Delay for ten seconds without passing the cancellation token, logging on each pass. Run it and press Ctrl+C in the middle of a delay. Time how long the process takes to exit.

Now pass the token to Task.Delay and repeat. Then add a log line on ApplicationStopping and one at the end of ExecuteAsync, and watch the order.

Show solution

Without the token, Ctrl+C does not interrupt the delay. The host waits for the shutdown timeout — 30 seconds by default — and then exits anyway. A worker like this adds half a minute to every deployment and gets killed mid-cycle at the end of it.

With the token, the delay throws a cancellation exception immediately and the process exits in well under a second. The host treats that exception during shutdown as a normal stop, not a failure.

The ordering makes the sequence concrete: the stopping notification fires first, then the loop unwinds, then StopAsync and disposal run. Every piece of shutdown behaviour you write hangs off that order, and it is worth watching once rather than inferring.

C#
// Misses the stop signal
await Task.Delay(TimeSpan.FromSeconds(10));

// Responds to it immediately
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);

Think about it

Do the grace period arithmetic

The employees API processes leave requests from a queue. A single request takes up to 8 seconds. ShutdownTimeout is left at the default 30 seconds. The deployment platform allows a 20-second termination grace period, and the load balancer takes about 5 seconds to stop routing traffic to an instance.

Which number wins, what happens on each deployment, and what would you change?

Show solution

The platform wins. It sends SIGTERM, waits 20 seconds, then sends SIGKILL, and nothing in the application can extend that. The host's 30-second patience is never reachable, so the last 10 seconds of it are fiction.

In practice most deployments are fine, because the work takes 8 seconds and finishes inside 20. The failures are the interesting ones: a request that starts just before the signal arrives, plus 5 seconds of traffic still being routed in, can push past 20 seconds and be killed part-way through. That is an occasional, unreproducible duplicate or half-applied leave request after a release.

A reasonable configuration: ShutdownTimeout around 15 seconds so the application gives up before the platform does and logs that it is doing so, and fail readiness on ApplicationStopping so the load balancer stops sending new work immediately. If 8-second units of work are unavoidable, raise the platform's grace period to 30 seconds and keep the application's timeout below it.

The general rule is an ordering, not a formula: load balancer reaction plus longest unit of work, then the application's shutdown timeout above that, then the platform's grace period above that. And regardless of the numbers, the work should be safe to retry, because SIGKILL cannot be negotiated with.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A container is sent SIGTERM during a rolling deployment. What does the host do first?
Your application's ShutdownTimeout is 60 seconds and the platform's termination grace period is 30 seconds. What is the practical effect?

Saved in this browser only.

End of the published lessons

That is everything written so far in .NET

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.