typescript 114 lines · 3 tabs

Scheduling and Processing Email Digests with a Bull Queue in NestJS

Shared by codesnips Jul 2026
3 tabs
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { ScheduleModule } from '@nestjs/schedule';
import { DigestProducer } from './digest.producer';
import { DigestProcessor } from './digest.processor';

@Module({
  imports: [
    ScheduleModule.forRoot(),
    BullModule.registerQueue({
      name: 'email-digest',
      defaultJobOptions: {
        attempts: 3,
        backoff: { type: 'exponential', delay: 5000 },
        removeOnComplete: true,
        removeOnFail: 1000,
      },
    }),
  ],
  providers: [DigestProducer, DigestProcessor],
})
export class DigestModule {}
3 files · typescript Explain with highlit

This snippet shows how recurring email digests are scheduled and processed in NestJS using @nestjs/bull, which wraps the Redis-backed Bull queue library. The design separates two concerns: a producer that decides when and for whom a digest should run, and a processor that does the actual work of building and sending each digest. Keeping these apart means the scheduling logic stays thin and the heavy, retry-prone work lives in isolated workers.

In DigestModule, the queue named email-digest is registered with BullModule.registerQueue, along with defaultJobOptions that apply to every job: three attempts with exponential backoff, plus removeOnComplete and a bounded removeOnFail so Redis does not accumulate dead job data indefinitely. Registering options at the queue level avoids repeating them at every add call.

DigestProducer combines a @Cron schedule from @nestjs/schedule with the injected queue. The @Cron handler enqueueDailyDigests runs once an hour, fetches the users due for a digest, and enqueues one job per user. The critical detail is jobId: it is derived from the user id and the current hour bucket, which makes the enqueue idempotent. If the cron fires twice, or two instances run concurrently, Bull deduplicates by jobId and only one job survives — this prevents duplicate emails, a common failure mode in distributed schedulers.

DigestProcessor is decorated with @Processor('email-digest') and marks handleDigest with @Process. It loads the user, guards against a missing or unsubscribed record by returning early (rather than throwing, which would trigger pointless retries), aggregates the digest content, and sends it. Any transient failure — say the mail provider times out — throws, and Bull re-runs the job under the backoff policy defined in the module. The lifecycle hooks @OnQueueFailed and @OnQueueCompleted provide observability without coupling to any specific logging stack.

The trade-off worth noting: idempotency here is keyed on a time bucket, so choosing the bucket granularity (per hour vs per day) determines the dedupe window. A reader would reach for this pattern whenever periodic, per-entity work must run reliably, survive restarts, and never double-fire.


Related snips

ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
ruby
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  default from: 'noreply@example.com'

  def welcome_email(user)
    @user = user

ActionMailer advanced patterns for transactional emails

ruby rails action-mailer
by Sarah Mitchell 2 tabs
ruby
json.array! @posts do |post|
  json.cache! ['v1', post], expires_in: 1.hour do
    json.id post.id
    json.title post.title
    json.excerpt post.excerpt
    json.published_at post.published_at

Fragment caching for expensive JSON serialization

rails caching performance
by Alex Kumar 1 tab
typescript
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";

const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";

JWT access + refresh token rotation (conceptual)

security node jwt
by codesnips 3 tabs
ruby
module EmailNormalization
  extend ActiveSupport::Concern

  included do
    attr_accessor :soft_warnings

Soft Validation: Normalize + Validate Email

rails activerecord validations
by codesnips 4 tabs
ruby
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
  def up
    execute <<~SQL
      CREATE MATERIALIZED VIEW top_sellers AS
        SELECT p.id            AS product_id,
               p.name          AS product_name,

Cache-Friendly “Top N” with Materialized View Refresh

rails postgres performance
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Scheduling and Processing Email Digests with a Bull Queue in NestJS — share card
Link copied