Blog

How to stop cron jobs from slowing down your ASP.NET database

Recurring jobs running inside your ASP.NET process compete with request handling for the same database connections. Here's why that happens and how to externalize the trigger.

SteadyCron dotnetaspnetdatabasescheduling

A surprisingly common cause of “the API gets slow every night at 2am” is a recurring job — a report, a backup, a cleanup query — running inside the same ASP.NET process that’s also serving requests, against the same database. Nobody planned for the two to fight each other; it just happens by default once a scheduler is wired into the app.

How it happens

Whether it’s a RecurringJob.AddOrUpdate in Hangfire, a CronTrigger in Quartz.NET, or a plain BackgroundService with a PeriodicTimer, the job runs inside the same process as your request pipeline. That means:

  • Shared connection pool. Your DbContext pool has a fixed size. A report job holding three long-running connections is three connections a request handler is now waiting on.
  • Shared CPU and GC. A job materializing a large result set for export triggers GC pressure that pauses every other thread in the process, including the ones serving requests.
  • Shared locks. A cleanup job doing a bulk UPDATE or DELETE can hold row or page locks that block reads from request handlers for the duration.
  • Scales with your app, not with the job. Running three instances behind a load balancer for request throughput means the job either fires three times (if you didn’t add distributed locking) or you’ve added the complexity of leader election just to schedule a report.

None of this shows up in development. It shows up at 2am in production, as a P99 latency spike nobody can reproduce locally — because locally there’s one instance, a tiny dataset, and no concurrent request load to contend with.

The fix isn’t always “optimize the query”

The instinct is to index the query, batch the delete, add NOLOCK hints. Sometimes that’s the real fix. But often the underlying issue isn’t the query — it’s that the job’s resource usage is coupled to your request-serving process and database connection pool at all, regardless of how well it’s written.

Decouple the trigger from the process

If the job doesn’t need to run inside your app — it’s not reacting to a request, it doesn’t need Hangfire’s queue or Quartz’s clustering — move the trigger outside the process entirely:

// A plain endpoint, not a background job
app.MapPost("/internal/cron/nightly-cleanup", async (AppDbContext db) =>
{
    await db.Database.ExecuteSqlAsync($"DELETE FROM stale_sessions WHERE ...");
    return Results.Ok();
}).RequireAuthorization("CronOnly");

Something outside your app — SteadyCron, in this case — calls that endpoint on schedule, with its own timeout and retry/backoff. The request still touches the same database, but now it’s a normal HTTP request your connection pool already knows how to handle, on your terms: you can run it from a separate deployment, throttle it, or point it at a read replica if the job is read-heavy.

This doesn’t fix a genuinely bad query. It does mean the job’s scheduling stops being coupled to your app’s process lifecycle, connection pool sizing, and horizontal scaling story — which is usually most of the actual problem.

If you’re not ready to move the trigger

You can still get visibility without touching where the job runs. The SteadyCron.Monitoring SDK wraps the existing method with TrackAsync, so you at least know how long the job is taking and get alerted if it stops running or starts running long — useful evidence for “is the nightly job actually the cause” before you restructure anything.

await monitor.TrackAsync("nightly-cleanup", async ct =>
{
    await CleanupStaleSessionsAsync(ct);
}, ct);
  • SteadyCron vs Hangfire — the fuller trade-off between in-process recurring jobs and an externally triggered schedule.
  • Quartz.NET vs Hangfire — if you’re choosing a scheduling library for the jobs that should stay in-process.