.NET y backend

Background Services en .NET: cuándo utilizarlos

Un BackgroundService resuelve tareas periódicas y colas internas sin infraestructura extra, pero tiene límites. Cómo hacerlo bien y cuándo ir más allá.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# de un BackgroundService con PeriodicTimer y un scope por ejecución

Hay que limpiar las sesiones vencidas cada hora. O enviar un correo después de un registro sin hacer esperar al usuario. La opción más rápida es un BackgroundService dentro de la misma API: sin colas, sin Azure Functions, sin otro despliegue.

Para muchos casos es la decisión correcta. Pero un servicio en segundo plano vive dentro del proceso de la API, y eso trae limitaciones que conviene conocer antes de confiarle algo importante.

La forma básica: tareas periódicas

public sealed class ExpiredSessionsCleanup(
    IServiceScopeFactory scopeFactory,
    ILogger<ExpiredSessionsCleanup> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        do
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var deleted = await db.Sessions
                    .Where(s => s.ExpiresAt < DateTime.UtcNow)
                    .ExecuteDeleteAsync(stoppingToken);

                logger.LogInformation("Sesiones vencidas eliminadas: {Count}", deleted);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // Sin este catch, una excepción detiene el servicio para siempre
                logger.LogError(ex, "Error limpiando sesiones vencidas");
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}

builder.Services.AddHostedService<ExpiredSessionsCleanup>();

Hay tres decisiones importantes en este código:

  1. Un scope por ejecución. El servicio en segundo plano es un singleton; el DbContext es scoped. Inyectarlo directamente crearía una dependencia cautiva (lo explicamos en errores frecuentes de Dependency Injection).
  2. PeriodicTimer en lugar de Task.Delay en un bucle: no acumula desfase y respeta la cancelación.
  3. Un try/catch dentro del bucle. Si una excepción escapa de ExecuteAsync, desde .NET 6 el comportamiento por defecto es detener la aplicación completa. Si la cambias para ignorar el error, el servicio se detiene en silencio y no vuelve a ejecutarse. Ninguna de las dos es lo que quieres para una limpieza periódica.

Una cola en memoria con Channels

Para trabajo que no debe hacer esperar al usuario, un Channel<T> funciona como una cola interna:

public sealed class EmailQueue
{
    private readonly Channel<EmailMessage> _channel =
        Channel.CreateBounded<EmailMessage>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait // presión hacia atrás si se llena
        });

    public ValueTask EnqueueAsync(EmailMessage message, CancellationToken ct) => _channel.Writer.WriteAsync(message, ct);
    public IAsyncEnumerable<EmailMessage> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);
}

public sealed class EmailSender(EmailQueue queue, IServiceScopeFactory scopes, ILogger<EmailSender> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var message in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await using var scope = scopes.CreateAsyncScope();
                await scope.ServiceProvider.GetRequiredService<IEmailService>().SendAsync(message, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "No se pudo enviar el correo a {To}", message.To);
            }
        }
    }
}

EmailQueue se registra como singleton, el endpoint encola y responde de inmediato, y el servicio procesa los correos uno por uno.

La limitación principal: todo vive en memoria

Si la aplicación se reinicia (un despliegue, un escalado hacia abajo, una caída), lo que estaba en el Channel se pierde. Para un correo de bienvenida puede ser aceptable. Para una factura, no.

Hay otras limitaciones del mismo origen:

  • Con varias instancias, cada una ejecuta su propio servicio. La limpieza cada hora corre tres veces si hay tres instancias. Las tareas periódicas tienen que ser seguras de ejecutar en paralelo, o necesitan un mecanismo de bloqueo distribuido.
  • Compiten por recursos con la API. Un proceso pesado en segundo plano consume la CPU y las conexiones que necesitan las peticiones de los usuarios.
  • El apagado tiene un límite. Al detenerse, el host espera a que los servicios terminen hasta un tiempo máximo (HostOptions.ShutdownTimeout, 30 segundos por defecto desde .NET 6). Lo que no termine en ese tiempo se interrumpe.
  • En algunos hostings la aplicación se duerme. Si el proceso se recicla por inactividad (IIS con su configuración por defecto, planes que suspenden la aplicación), los temporizadores dejan de ejecutarse.

Un cambio de .NET 10

Hasta .NET 9, la parte de ExecuteAsync anterior al primer await se ejecutaba de forma síncrona durante el arranque, y un servicio que hacía trabajo pesado antes de su primer await retrasaba el inicio de la aplicación. Desde .NET 10, ExecuteAsync completo se ejecuta en segundo plano. Si tu código dependía de que algo estuviera listo al arrancar, revísalo.

Cuándo usar Background Services

  • Tareas periódicas de mantenimiento que pueden ejecutarse más de una vez sin problema.
  • Trabajo de baja importancia que puede perderse en un reinicio (o que se puede reconstruir).
  • Aplicaciones con una sola instancia y requisitos modestos.
  • Procesos que consumen de una cola duradera (Service Bus, una tabla en la base de datos), donde el servicio solo es el consumidor y el mensaje no se pierde si el proceso cae.

Cuándo no

  • Trabajo que no puede perderse: pagos, facturas, integraciones con terceros. Ahí hace falta una cola duradera como Azure Service Bus o, como mínimo, una tabla de tareas pendientes en la base de datos.
  • Procesos pesados que afectan el rendimiento de la API: mejor en un worker desplegado aparte.
  • Tareas programadas que deben ejecutarse exactamente una vez con varias instancias: un programador con bloqueo distribuido (Hangfire, Quartz con almacenamiento compartido) o una función con disparador de tiempo.

El trade-off

Un BackgroundService es la forma más simple de ejecutar trabajo asíncrono: sin infraestructura nueva, sin otro despliegue, con el mismo código y configuración de la API. A cambio, comparte el destino del proceso: si la API se reinicia, el trabajo pendiente se reinicia o se pierde con ella.

Conclusión

Para mantenimiento periódico y trabajo que tolera perderse, un BackgroundService bien hecho (con scope por ejecución, captura de errores y PeriodicTimer) es suficiente. Cuando el trabajo importa, la cola tiene que vivir fuera del proceso.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Necesitas un equipo .NET para tu proyecto?

Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.

Ver desarrollo a medida