.NET BACKGROUND JOB

Reliable Background Jobs in .NET: A Production-Grade Pattern

Key Insight – Most .NET background job systems compromise reliability or performance. This article reveals common flaws and presents a robust alternative.


The Critical Role of Background Jobs

Background processing handles essential tasks that shouldn’t block user requests:

  • Email/SMS delivery
  • Data aggregation
  • Batch processing
  • Scheduled reports

Poor implementations risk:

  • Data loss when jobs disappear during crashes
  • Performance degradation from thread starvation
  • Unrecoverable failures with no retry mechanism

Why Common Solutions Fail

Problem Area Consequences Real-World Example
Non-transactional enqueue Jobs vanish if process crashes mid-execution Hangfire’s delayed job storage
Shared thread pools Background work starves HTTP requests Quartz.NET competing with ASP.NET
Unhandled failures Silent job disappearance MediatR background tasks failing without logs

Robust Implementation Blueprint

1. Job Definition Structure

public sealed record BackgroundJob(
    Guid Id,
    string HandlerType,  // Full type name for DI resolution
    string Payload,      // Serialized command data
    DateTimeOffset EnqueuedAt,
    int RetryCount = 0,
    DateTimeOffset? NextAttempt = null);

2. Transactional Job Creation

public async Task<Guid> EnqueueAsync<TCommand>(TCommand command, 
    CancellationToken ct = default)
{
    var job = new BackgroundJob(
        Id: Guid.NewGuid(),
        HandlerType: typeof(TCommand).AssemblyQualifiedName!,
        Payload: JsonSerializer.Serialize(command),
        EnqueuedAt: DateTimeOffset.UtcNow);

    await using var tx = await _db.BeginTransactionAsync(ct);
    await _db.ExecuteAsync(
        @"INSERT INTO BackgroundJobs (Id, HandlerType, Payload, EnqueuedAt)
          VALUES (@Id, @HandlerType, @Payload, @EnqueuedAt)", 
        job, tx);
    await tx.CommitAsync(ct);

    _jobChannel.Writer.TryWrite(job.Id);
    return job.Id;
}

3. Scalable Worker Service

public sealed class JobWorker : BackgroundService
{
    private readonly ChannelReader<Guid> _reader;
    private readonly IServiceProvider _services;
    private readonly int _maxDegree;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var semaphore = new SemaphoreSlim(_maxDegree);
        var tasks = new List<Task>();

        await foreach (var jobId in _reader.ReadAllAsync(stoppingToken))
        {
            await semaphore.WaitAsync(stoppingToken);
            tasks.Add(Task.Run(async () => 
            {
                try { await ProcessJobAsync(jobId, stoppingToken); }
                finally { semaphore.Release(); }
            }, stoppingToken));
        }

        await Task.WhenAll(tasks);
    }

    private async Task ProcessJobAsync(Guid jobId, CancellationToken ct)
    {
        await using var scope = _services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IDbConnection>();

        var job = await db.QuerySingleOrDefaultAsync<BackgroundJob>(
            @"SELECT TOP (1) * FROM BackgroundJobs WITH (UPDLOCK, READPAST)
              WHERE Id = @Id AND (NextAttempt IS NULL OR NextAttempt <= SYSDATETIMEOFFSET())",
            new { Id = jobId }, ct);

        if (job is null) return;

        var handler = CreateHandler(scope.ServiceProvider, job);
        var command = DeserializePayload(job);

        try
        {
            await handler.HandleAsync(command, ct);
            await CompleteJobAsync(db, job.Id, ct);
        }
        catch (Exception ex)
        {
            await HandleFailureAsync(db, job, ex, ct);
        }
    }
}

Architectural Advantages

  1. Guaranteed persistence – Jobs survive process crashes
  2. Isolated execution – Dedicated worker threads prevent ASP.NET interference
  3. Controlled retries – Exponential backoff with jitter
  4. Horizontal scaling – Database locking enables multi-instance deployment

Performance Optimizations

Technique Impact
Channel-based queuing Reduces database polling
Async semaphore Maintains optimal concurrency
Connection reuse Minimizes connection overhead
Skipped locks Allows parallel job processing

Alternative Solutions Guide

Requirement Recommended Approach
Sub-millisecond latency MemoryChannel with persistent fallback
50k+ jobs/second Dedicated broker (Kafka/RabbitMQ)
Cross-service transactions Outbox pattern with CDC

Deployment Checklist

  1. [ ] Configure database locks (UPDLOCK, READPAST)
  2. [ ] Set concurrency limits based on workload
  3. [ ] Implement dead-letter monitoring
  4. [ ] Add Prometheus metrics/health checks
  5. [ ] Establish retry policies with jitter

Common Questions

Q: How does this improve on Hangfire?
A: Guarantees job persistence before execution and prevents thread pool exhaustion.

Q: PostgreSQL compatibility?
A: Replace SQL Server hints with FOR UPDATE SKIP LOCKED.

Q: Throughput limits?
A: Capable of ~5k jobs/sec on modest hardware. Beyond that, consider a message broker.


Final Recommendation

This pattern delivers:

  • Reliability through transactional storage
  • Performance via proper thread isolation
  • Maintainability with clear error handling
  • Scalability using database-backed queues

It satisfies the requirements for most .NET applications while avoiding common pitfalls found in off-the-shelf solutions.

Leave a Comment

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