Programming

Stop Using Task.Delay Loops for Scheduled Work in .NET

May 9, 2026

PeriodicTimer is one of those small .NET features that quietly fixes a lot of sloppy background-service code.

I still see scheduled worker logic built around while loops and Task.Delay(). It works well enough right up until it doesn’t. The timing drifts, cancellation gets awkward, exceptions land in odd places, and the code slowly turns into a little homemade scheduler.

That is usually unnecessary.

If your job just needs to run on a steady interval inside a .NET worker, PeriodicTimer is usually the cleaner tool.

The common Task.Delay pattern looks fine at first

A lot of background services start life like this:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await DoWorkAsync(stoppingToken);
        await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
    }
}

There is nothing shocking here.

It is simple, readable, and very common.

The trouble is that this pattern quietly mixes two different concerns:

  • running the work
  • deciding when the next run should happen

That gets messy faster than people expect.

Where Task.Delay loops start to go bad

The first problem is drift.

If DoWorkAsync() takes 40 seconds, and your delay is 5 minutes, then your job is not really running every 5 minutes. It is running every 5 minutes plus however long the work itself takes.

Sometimes that is acceptable.

Sometimes it absolutely is not.

Then there is shutdown behaviour. If the service is stopping while you are in the delay call, cancellation will usually work fine. But teams often end up layering extra flags, try/catch blocks, and timing logic around that loop as the worker gets more complicated.

And once retries, backoff rules, or partial failures get added, the loop stops feeling like a simple interval runner and starts feeling like a homemade orchestration problem.

That is not where you want to be.

What PeriodicTimer does better

PeriodicTimer separates the interval from the body of the work more cleanly.

A basic version looks like this:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));

    while (await timer.WaitForNextTickAsync(stoppingToken))
    {
        await DoWorkAsync(stoppingToken);
    }
}

That is easier to reason about.

The timer controls the cadence. The loop body does the work. Those jobs are no longer tangled together in the same way.

It also makes the intent obvious when someone reads the service later: this worker runs on a periodic tick until cancellation is requested.

The timing behaviour is usually what people actually meant

This is the part that matters.

With PeriodicTimer, you are expressing a recurring schedule more directly. You are not manually sleeping after each iteration and hoping that behaviour matches the operational expectation.

That does not turn your worker into a full scheduler. It still is not cron. It still is not a calendar-aware system. But for steady in-process interval work, it is a better fit.

Examples where this usually helps:

  • polling an internal queue or feed every few minutes
  • refreshing cached data on a regular cadence
  • syncing low-risk background state
  • checking for maintenance conditions inside a hosted service

If the requirement is basically “run this on a recurring interval while the process is alive,” PeriodicTimer is a strong default.

One important design choice: immediate run or first tick?

This is where people get tripped up.

A Task.Delay() loop often runs immediately, then waits.

A PeriodicTimer loop waits for the first tick before running the body.

That means these two patterns are not identical.

If you want the work to execute immediately on startup, do it deliberately:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    await DoWorkAsync(stoppingToken);

    using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));

    while (await timer.WaitForNextTickAsync(stoppingToken))
    {
        await DoWorkAsync(stoppingToken);
    }
}

That small difference matters.

Do not switch patterns blindly and then wonder why the first run is now delayed.

This does not solve every scheduling problem

PeriodicTimer is better, but it is not magic.

You still need to think about:

  • what should happen if one run takes longer than the interval
  • whether overlapping work is allowed
  • how retries should behave
  • whether failures should stop the service or be logged and continued
  • whether the job really belongs inside the web app process at all

Those are system-design questions, not timer questions.

But that is exactly why I like PeriodicTimer. It solves the timing part cleanly without pretending to solve the bigger architecture questions for you.

A practical rule I’d use

Here is the rule of thumb.

Use PeriodicTimer when:

  • the job runs on a simple recurring interval
  • the work belongs in a hosted service
  • you want clear cancellation behaviour
  • you do not need calendar-based scheduling

Use something else when:

  • the task must run at exact wall-clock times
  • you need persistence across restarts
  • schedules are user-defined or business-defined
  • missed runs need recovery logic

People sometimes try to stretch in-process timers into job schedulers.

That is usually where avoidable nonsense begins.

Final thought

A while loop plus Task.Delay() is not always wrong.

It is just often the lazy default.

If you are building a recurring background worker in modern .NET, PeriodicTimer usually says what you mean more clearly and with fewer sharp edges. That alone is a good reason to prefer it.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.