A background job that syncs or imports thousands of rows often starts fast and gets slower with every row. A run that did 60 chunks in its first hour is down to 10 a day later, and it never logs "complete". The database is usually innocent. The cause is the EF Core change tracker, quietly holding every entity the job has ever touched.
Job frameworks like Hangfire create one dependency injection scope per job execution. For a web request, a scope lives for milliseconds. For a bulk job, that same scope (and every scoped DbContext in it) lives for the entire run, which can be hours or days.
Every entity the job loads or adds stays in the change tracker. Each SaveChangesAsync then walks all tracked entities to detect changes, and any SaveChanges interceptors (audit stamping, outbox) walk them all again. Chunk 100 pays for the 99 chunks before it, so each save costs more than the last. Eventually a save crosses the SQL command timeout and the run dies without finishing.
Process the data in chunks (see Do you bulk process in chunks?), then look at what owns the DbContext:
public class SyncAllProductsJob(AppDbContext dbContext){public async Task Execute(CancellationToken ct){var ids = await GetProductIdsToSync(ct);foreach (var chunk in ids.Chunk(500)){var products = await dbContext.Products.Where(p => chunk.Contains(p.Id)).ToListAsync(ct);ApplyChanges(products);await dbContext.SaveChangesAsync(ct);}}}
❌ Figure: Bad example - One injected DbContext for the whole run. Every chunk's entities stay tracked, so every save walks all of them again
If you keep the single context, empty its tracker at the end of every chunk. Put the clear in a finally so a failed chunk clears too:
foreach (var chunk in ids.Chunk(500)){try{var products = await dbContext.Products.Where(p => chunk.Contains(p.Id)).ToListAsync(ct);ApplyChanges(products);await dbContext.SaveChangesAsync(ct);}finally{dbContext.ChangeTracker.Clear();}}
😐 Figure: OK example - Clearing the tracker caps the cost, but it relies on every code path remembering to clear
Only clear at the chunk boundary. If one chunk involves two saves (save the parents, then save their children), a Clear() between them detaches the parents, and the children are silently never written. No exception, just missing data. Keep the clear in one place: the finally on the loop. Never put it inside the methods that do the saves.
Figure: Warning - A misplaced Clear() causes silent data loss, which is worse than the slowdown it fixes
DbContext is designed to be short-lived. Instead of reusing one context and cleaning up after it, create one per chunk and throw it away. There is nothing to remember and nothing to leak.
Register the factory:
services.AddDbContextFactory<AppDbContext>(options =>options.UseSqlServer(connectionString));
Then create a context per chunk:
public class SyncAllProductsJob(IDbContextFactory<AppDbContext> dbContextFactory){public async Task Execute(CancellationToken ct){var ids = await GetProductIdsToSync(ct);foreach (var chunk in ids.Chunk(500)){await using var dbContext = await dbContextFactory.CreateDbContextAsync(ct);var products = await dbContext.Products.Where(p => chunk.Contains(p.Id)).ToListAsync(ct);ApplyChanges(products);await dbContext.SaveChangesAsync(ct);}}}
✅ Figure: Good example - Each chunk gets a brand-new DbContext and disposes it, so nothing accumulates between chunks
Many jobs never touch the DbContext directly. They call sync services or MediatR handlers that get their context from dependency injection, and a factory can't reach those contexts. Create a DI scope per chunk instead, and resolve the services from it. Disposing the scope disposes every scoped DbContext inside it, including one hiding behind an ISender or one a teammate adds next year:
public class SyncAllProductsJob(IServiceScopeFactory scopeFactory){public async Task Execute(CancellationToken ct){var ids = await GetProductIdsToSync(ct);foreach (var chunk in ids.Chunk(500)){using var scope = scopeFactory.CreateScope();var syncService = scope.ServiceProvider.GetRequiredService<ProductSyncService>();await syncService.SyncProducts(chunk, ct);}}}
✅ Figure: Good example - For service graphs, a scope per chunk plays the factory's role: the scope owns every DbContext the chunk used
Log a short heartbeat per chunk (rows processed, elapsed time, rows per second) so the decay shows up in one log line instead of being reconstructed from timestamps after the incident.
For reads the job never modifies, keep entities out of the tracker entirely. See Do you use AsNoTracking for readonly queries?.