Terug naar blog
saasarchitectuurbackendscalingdevops

Background jobs en queue-architectuur voor je SaaS: de complete gids

Door SaaS Masters10 maart 20266 min leestijd
Background jobs en queue-architectuur voor je SaaS: de complete gids

Elke succesvolle SaaS bereikt een punt waarop niet alles meer in een HTTP-request kan worden afgehandeld. E-mails versturen, PDF's genereren, data importeren, AI-modellen aanroepen — als je dit synchroon doet, worden je gebruikers gek van de laadtijden. De oplossing? Background jobs en een goede queue-architectuur.

In dit artikel laten we zien hoe je een robuust systeem voor achtergrondtaken opzet, welke patronen werken, en welke valkuilen je moet vermijden.

Waarom background jobs onmisbaar zijn

Stel je voor: een gebruiker uploadt een CSV met 10.000 contacten. Zonder background jobs moet die gebruiker wachten tot alles verwerkt is — dat kan minuten duren. Met een queue-systeem geef je direct feedback ("Je import wordt verwerkt") en handel je het werk op de achtergrond af.

Typische use cases in SaaS:

  • 📧 Transactionele e-mails en notificaties
  • 📊 Rapportages en exports genereren
  • 🔄 Data-synchronisatie met externe systemen
  • 🤖 AI/ML-verwerking (embeddings, classificatie)
  • 💳 Webhooks van betaalproviders verwerken
  • 📁 Bestandsverwerking (resize, conversie, virus-scan)
  • 🧹 Periodieke opschoning en onderhoud

De anatomie van een queue-systeem

Een queue-systeem bestaat uit drie kerncomponenten:

1. Producer (de afzender)

De code die een taak op de queue plaatst:

import { queue } from './lib/queue';

export async function handleCSVUpload(userId: string, fileUrl: string) {
  const importRecord = await db.import.create({
    data: { userId, status: 'processing', fileUrl }
  });

  await queue.add('process-csv-import', {
    userId,
    fileUrl,
    importId: importRecord.id,
  }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
    timeout: 300_000, // 5 minuten max
  });

  return { status: 'processing', importId: importRecord.id };
}

2. Queue (de wachtrij)

De buffer tussen producer en consumer. Populaire opties:

TechnologieVoordelenNadelen
Redis + BullMQSnel, mature, goede DXRedis beheren / hosten
PostgreSQL + pgBossGeen extra infra nodigMinder performant bij hoog volume
AWS SQSBeheerd, schaalbaarVendor lock-in, hogere latency
RabbitMQFeature-rijk, routingComplexer om te beheren

Onze aanbeveling voor de meeste SaaS-projecten: BullMQ met Redis. Het biedt de beste balans tussen features, performance en developer experience. Voor kleinere projecten is pgBoss een slimme keuze — je hebt geen extra infrastructuur nodig.

3. Worker (de verwerker)

Het proces dat taken van de queue oppakt en uitvoert:

import { Worker } from 'bullmq';
import { redis } from './lib/redis';

const worker = new Worker('process-csv-import', async (job) => {
  const { userId, fileUrl, importId } = job.data;
  
  const records = await downloadAndParseCSV(fileUrl);
  
  const batchSize = 100;
  for (let i = 0; i < records.length; i += batchSize) {
    const batch = records.slice(i, i + batchSize);
    await processContactBatch(userId, batch);
    
    await job.updateProgress(
      Math.round((i / records.length) * 100)
    );
  }
  
  await db.import.update({
    where: { id: importId },
    data: { status: 'completed', processedCount: records.length }
  });
  
  await sendNotification(userId, 
    'Je import van ' + records.length + ' contacten is voltooid!'
  );
}, { connection: redis, concurrency: 5 });

worker.on('failed', (job, err) => {
  console.error('Job ' + job?.id + ' mislukt:', err);
});

Essentiële patronen voor productie

Retry met exponential backoff

Externe services gaan soms even down. Bouw altijd retry-logica in:

await queue.add('send-email', emailData, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 2000, // 2s, 4s, 8s, 16s, 32s
  },
});

Dead Letter Queue (DLQ)

Jobs die na alle retries nog falen, moeten ergens heen — niet stilletjes verdwijnen:

worker.on('failed', async (job, err) => {
  if (job && job.attemptsMade >= job.opts.attempts) {
    await deadLetterQueue.add('failed-job', {
      originalQueue: 'send-email',
      jobData: job.data,
      error: err.message,
      failedAt: new Date().toISOString(),
    });
    
    await alertOps('DLQ: Job ' + job.id + ' definitief mislukt');
  }
});

Idempotency — de gouden regel

Een job kan meerdere keren uitgevoerd worden (door retries, crashes, deploys). Zorg dat dubbele uitvoering geen schade aanricht:

async function processPaymentWebhook(job) {
  const { eventId, paymentId } = job.data;
  
  const existing = await db.processedEvent.findUnique({
    where: { eventId }
  });
  
  if (existing) {
    console.log('Event ' + eventId + ' al verwerkt, overslaan');
    return;
  }
  
  await db.$transaction([
    db.processedEvent.create({ data: { eventId } }),
    db.subscription.update({
      where: { paymentId },
      data: { status: 'active' }
    }),
  ]);
}

Prioriteiten en gescheiden queues

Niet elke taak is even urgent. Scheid je queues op basis van prioriteit:

// Hoge prioriteit: betaling-gerelateerd
await queue.add('process-payment', data, { priority: 1 });

// Normale prioriteit: e-mail
await queue.add('send-email', data, { priority: 5 });

// Lage prioriteit: analytics
await queue.add('update-analytics', data, { priority: 10 });

Of gebruik aparte queues met dedicated workers, zodat een golf aan analytics-jobs je betalingsverwerking niet vertraagt.

Scheduled jobs en cron-taken

Naast event-driven jobs heb je ook periodieke taken nodig:

await queue.add('daily-report', {}, {
  repeat: { pattern: '0 8 * * *' },
  jobId: 'daily-report',
});

await queue.add('trial-expiry-check', {}, {
  repeat: { pattern: '0 */4 * * *' },
  jobId: 'trial-expiry-check',
});

await queue.add('cleanup-old-data', {}, {
  repeat: { pattern: '0 3 * * 0' },
  jobId: 'cleanup-old-data',
});

Monitoring en observability

Een queue-systeem zonder monitoring is een tikkende tijdbom. Implementeer dashboards voor:

  • Queue depth — Hoeveel jobs staan er in de wachtrij?
  • Processing time — Hoe lang duurt een gemiddelde job?
  • Failure rate — Welk percentage jobs faalt?
  • DLQ size — Hoeveel jobs zijn definitief mislukt?
async function collectQueueMetrics() {
  const counts = await queue.getJobCounts(
    'active', 'completed', 'failed', 'delayed', 'waiting'
  );
  
  await metrics.gauge('queue.waiting', counts.waiting);
  await metrics.gauge('queue.active', counts.active);
  await metrics.gauge('queue.failed', counts.failed);
  
  if (counts.waiting > 1000) {
    await alertOps('Queue depth > 1000, controleer workers!');
  }
}

Deployment en scaling

Workers als apart proces

Draai workers niet in je webserver-proces. Ze verdienen hun eigen deployment:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist/ ./dist/
CMD ["node", "dist/worker.js"]
services:
  web:
    build: .
    ports: ["3000:3000"]
  
  worker:
    build:
      dockerfile: Dockerfile.worker
    deploy:
      replicas: 3
    depends_on:
      - redis
  
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

Graceful shutdown

Bij een deploy moeten lopende jobs netjes worden afgemaakt:

async function gracefulShutdown() {
  console.log('Shutdown gestart, huidige jobs afronden...');
  await worker.close();
  await redis.quit();
  process.exit(0);
}

process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

Veelgemaakte fouten

❌ Te veel data in de job payload

// Fout: hele bestand in de job stoppen
await queue.add('process', { csvData: entireFileContent }); // Te groot

// Goed: referentie opslaan, worker haalt data op
await queue.add('process', { fileUrl: 's3://bucket/file.csv' }); // Compact

❌ Geen timeout instellen

Een hangende job kan je hele queue blokkeren. Stel altijd een timeout in.

❌ Queue state in geheugen bijhouden

Als je worker crasht, ben je alles kwijt. Gebruik altijd een persistent backing store (Redis, PostgreSQL).

❌ Geen monitoring

"We merken het wel als gebruikers klagen" is geen strategie.

Conclusie

Background jobs zijn geen nice-to-have — ze zijn een fundamenteel onderdeel van elke schaalbare SaaS. Begin simpel met BullMQ of pgBoss, implementeer de basispatronen (retry, idempotency, monitoring), en breid uit naarmate je groeit.

De investering in een goed queue-systeem betaalt zich dubbel terug: betere gebruikerservaring, hogere betrouwbaarheid, en een architectuur die klaar is voor schaal.

Wil je hulp bij het opzetten van een robuuste achtergrondverwerking in je SaaS? Neem contact met ons op — we helpen je graag.