← All posts

Cadence belongs on the method, not in startup

Why we built drift-corrected, attribute-driven recurring tasks with three call-site overloads — and why we don't think you should reach for Timer, PeriodicTimer, or BackgroundService directly anymore.

Most teams reimplement the same recurring-task wiring three times before they get tired of it. Timer for the prototype, PeriodicTimer for the second attempt, then BackgroundService for production. Each version has subtle bugs that only surface under load.

We were on our fourth implementation when we sat down and built Kanject.Core.Recurring.

The five footguns

Running a method on a schedule sounds trivial. In production it isn’t:

  1. Re-entrancy — your last iteration is still running when the next tick fires. The Timer API will happily call your callback in parallel and your code will deadlock or double-write.
  2. Drift — naïve await Task.Delay(interval) loops accumulate scheduling error indefinitely. Iteration 100 fires noticeably later than iteration 1.
  3. CancellationTimer doesn’t take a CancellationToken. PeriodicTimer does, but you have to thread it through every iteration manually. Most people don’t.
  4. Exception swallowing — a silently caught exception in a loop is a production incident waiting to happen. A loop that throws and dies on the first hiccup is also a production incident. Neither default is right.
  5. Cadence is configuration, not code — embedding TimeSpan.FromSeconds(30) means every cadence change is a redeploy and a code review for what is actually an operational parameter.

We picked four of these the hard way.

What we built

A small attribute + source generator + engine triplet:

using Kanject.Core.Annotations.Attributes.Recurring;

public sealed class HeartbeatService
{
    [Recurring(5, RecurringRateUnit.Seconds)]
    public async Task SendHeartbeatAsync(CancellationToken ct)
    {
        await _client.PingAsync(ct);
    }
}

// Call site — the extension is generated:
var service = new HeartbeatService();
var result  = await service.SendHeartbeatRecurringAsync(cancellationToken: ct);

Console.WriteLine($"Ran {result.Iterations} times, stopped because {result.StopReason}");

The principle: cadence and lifecycle belong on the method declaration; the call site is just await .RecurringAsync(...).

Three overloads when the method returns something

For value-returning methods the generator emits three call-site shapes — pick the one that fits the consumer:

public static class PriceFeed
{
    [Recurring(1, RecurringRateUnit.Seconds, MaxIterations = 60)]
    public static async ValueTask<decimal> FetchCurrentPriceAsync(
        string symbol, CancellationToken ct)
    {
        return await Exchange.GetPriceAsync(symbol, ct);
    }
}

// (A) Aggregated — runs to completion, returns RecurringResult<T>
var run = await PriceFeed.FetchCurrentPriceRecurringAsync("BTC", ct: ct);
Console.WriteLine($"Last price: {run.LastValue}, iterations: {run.Iterations}");

// (B) Streaming — yields each iteration's value as IAsyncEnumerable<T>
await foreach (var price in PriceFeed.FetchCurrentPriceRecurringStream("BTC", ct: ct))
    Console.WriteLine($"Tick: {price}");

// (C) Last-value convenience — returns Task<T?>; throws if the run faulted
var last = await PriceFeed.FetchCurrentPriceRecurringToLastAsync("BTC", ct: ct);

Three shapes, one declaration, no manual scheduler in any of them.

Drift correction, the right way

Most “recurring” libraries handle drift by accumulating it. Recurring doesn’t.

startTs = Stopwatch.GetTimestamp();              // recorded once
nextTick = startTs + (i + 1) * interval;         // each tick scheduled absolutely
delayUntil(nextTick - elapsedSinceStart);        // delay is always relative to start

That’s it. A slow iteration N does not push iteration N+1 further into the future than it would have been anyway. We use Stopwatch.GetTimestamp and Stopwatch.GetElapsedTime for monotonic, sub-microsecond timing — never DateTime.Now in the hot path.

If you’re scheduling 86,400 ticks a day on a 1-second cadence, the difference between drift-corrected and naïve scheduling at the end of the day is the difference between 24 hours of work and 24 hours plus several minutes of accumulated lag. We’ve seen the latter cause a daily aggregation Lambda to overflow its 15-minute timeout.

Overrun policies — pick your poison explicitly

What happens when iteration N takes longer than the interval?

PolicyBehaviour
Drop (default)Skip missed ticks. Advance the iteration counter so the next tick is strictly in the future. Right for idempotent work.
CatchUpRun the next iteration immediately, with no delay. Right for queues that must drain.
StopStop the loop and surface RecurringStopReason.Overrun. Right for correctness-critical work that must alert on backpressure.

The default is Drop because it’s the safest behaviour for the most common case (heartbeats, polling, refresh loops). Don’t pick CatchUp unless you’re confident your work is fast enough to actually catch up — otherwise it accelerates a queue that’s already backing up.

Three hosting surfaces, one engine

Because the engine is a static class that returns a Task (or IAsyncEnumerable), it fits any surface without modification:

// AWS Lambda warm-invocation loop — drain until the deadline approaches
public async Task FunctionHandler(Stream input, ILambdaContext ctx)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.CancellationToken);
    cts.CancelAfter(ctx.RemainingTime - TimeSpan.FromSeconds(5));
    await _service.SweepRecurringAsync(cancellationToken: cts.Token);
}

// IHostedService — no Kanject hosting helpers required
public sealed class SweepHost(BatchSweeper sweeper) : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
        sweeper.SweepRecurringAsync(cancellationToken: stoppingToken);
}

If you’d rather skip writing the BackgroundService yourself, add [RecurringHosted] alongside [Recurring] and the generator emits one for you, plus a typed IServiceCollection.AddYourServiceMethodRecurring(...) extension.

public sealed class WeatherPoller(IWeatherClient client)
{
    [Recurring(30, RecurringRateUnit.Seconds, MaxJitterMs = 500)]
    [RecurringHosted(Name = "WeatherPolling", Lifetime = RecurringServiceLifetime.Scoped)]
    public ValueTask PollAsync(CancellationToken ct) => client.FetchAsync(ct);
}

// Startup
services.AddWeatherPollerPollRecurring(config.GetSection("Recurring:WeatherPolling"));

Lifetime = Scoped creates a fresh DI scope per iteration — EF-safe. Singleton resolves the owner once.

Important caveat: don’t use [RecurringHosted] in AWS Lambda. The Lambda runtime is event-driven; long-running BackgroundServices don’t fit. Call FooRecurringAsync from your handler instead, as in the warm-loop example above.

Hooks — per-call and global

// Per-call
var r = await svc.WorkRecurringAsync(options: new RecurringOptions
{
    OnIteration  = iter => Metrics.Emit("work.tick", iter.Elapsed),
    OnException  = (ex, idx) => Telemetry.Capture(ex, idx),
    StopPredicate = value => value is null,   // value-returning overloads only
}, cancellationToken: ct);

// Global (process-wide)
RecurringHookProvider.OnIteration = (name, iter) => Metrics.Emit($"{name}.tick", iter.Elapsed);
RecurringHookProvider.OnException = (name, ex, idx) => Telemetry.Capture(name, ex, idx);

name defaults to {ContainingType}.{Method} — so dashboards group by method without the consumer doing anything.

Jitter for thundering herds

// Every 60s, ±0–2000ms randomised jitter added to each delay
[Recurring(60, RecurringRateUnit.Seconds, MaxJitterMs = 2000)]
public async Task RefreshCacheAsync(CancellationToken ct) { ... }

Sampled per-iteration via Random.Shared.NextInt64 — thread-safe, allocation-free.

What we don’t do

  • We don’t coordinate across replicas. If you run multiple instances of your host, every replica runs its own loop. Distributed leader election is a post-1.0 concern.
  • We don’t run on Timer. Re-entrancy semantics are too fragile for production.
  • We don’t accept arbitrary parameter shapes on [RecurringHosted] methods — the analyzer (KANREC011) requires either no parameters or a single CancellationToken. Wrap arg-taking methods in a zero-arg shim.

The takeaway

Cadence belongs on the method. Hosting belongs at the surface. The call site should be one line.

If you’re shipping [Recurring] in our stack, it’s in Kanject.Core.Annotations. If you’re not, the principle is portable: write a small attribute, point a Roslyn generator at it, emit typed extensions over a drift-corrected engine. The whole thing is fewer lines than the average BackgroundService your team already maintains.

More field notes from the team.

Engineering April 15, 2026

Parallel.ForEachAsync wasn't enough. So we built our own.

The four things every batched-I/O workload needs — scoped DI, retry policy, per-partition timeout, and partial-batch acknowledgement — none of which the BCL gives you in one place. Here's why we shipped Kanject.Core.Parallel.

Engineering March 18, 2026

How we ship source generators that survive refactors

Lessons from two years of building Roslyn generators that don't silently break when downstream code moves.

Engineering February 5, 2026

From Console.WriteLine to ILogger without changing a single call site

Developers love Console.WriteLine. Production hates it. We built a compile-time severity inference + interceptor system so the same call sites route through ILogger when you ship — zero refactoring.