Background jobs en workers in Node.js uitgelegd

Leer hoe background jobs en workers in Node.js werken. Van BullMQ tot worker threads, met praktische voorbeelden, queues en best practices.

12 juli 20266 min leestijdDoor We Develop Communication

Een gebruiker die 30 seconden op een laadspinner staart omdat jouw server een PDF aan het genereren is, is een verloren gebruiker. De oplossing: haal die zware taken uit de request-response cyclus en laat background jobs en workers het werk doen op de achtergrond. Op die manier blijft je Node.js API snel en responsief, terwijl zware taken netjes in een queue worden afgehandeld.

In deze gids kijken we naar waarom je background jobs nodig hebt, welke tools er zijn in het Node.js-ecosysteem, en hoe je een robuuste worker-setup bouwt met BullMQ. We bouwen voort op eerder behandelde onderwerpen zoals de event loop en async programming.

Waarom background jobs?

Node.js is single-threaded en draait op een event loop. Dat maakt het razendsnel voor I/O, maar kwetsbaar voor alles wat de loop blokkeert of lang duurt. Een HTTP-request hoort binnen 100–300ms afgehandeld te zijn.

Zodra een taak langer duurt, wil je dat asynchroon verwerken. Typische kandidaten:

  • Transactionele e-mails en notificaties versturen
  • PDF- of rapportgeneratie
  • Afbeeldingen resizen of video's encoderen
  • Webhooks en integraties met trage externe API's
  • Periodieke batchverwerking (cron-achtige taken)
  • Data-imports en exports
  • Retry-gevoelige operaties zoals betaalreconciliatie

De oplossing is altijd hetzelfde patroon: je webserver zet een job op een queue, reageert direct met een 202 Accepted, en één of meerdere workers pakken de job later op.

Queues, producers en workers

Een background-job systeem bestaat uit drie onderdelen:

  1. Producer, je webserver die jobs op een queue zet.
  2. Queue, meestal Redis, RabbitMQ of een database. Houdt pending, running en completed jobs bij.
  3. Worker, een apart Node.js-proces dat jobs uit de queue haalt en verwerkt.

Het grote voordeel van deze scheiding: producers en workers schalen onafhankelijk. Je kunt één API-server hebben en tien workers, of andersom. Crasht een worker? Dan blijft je API gewoon draaien en pakt een andere worker de job op.

Worker threads vs. background workers

Twee termen die vaak door elkaar worden gehaald:

  • Worker threads (node:worker_threads): echte threads binnen hetzelfde Node.js-proces. Perfect voor CPU-intensieve taken zoals hashing, parsing of image processing die anders de event loop blokkeren.
  • Background workers: aparte Node.js-processen die jobs uit een externe queue oppakken. Perfect voor I/O-gebonden, langlopende of periodieke taken.

Voor het merendeel van webapplicaties werk je met background workers. Worker threads gebruik je alleen als je echt CPU-bound werk hebt dat je in-process wil versnellen. Meer over het verschil lees je in de officiële Node.js docs over Worker threads.

BullMQ: de de-facto standaard

In het Node.js-ecosysteem is BullMQ op dit moment de meest gebruikte library voor background jobs. Het draait op Redis, is TypeScript-first en ondersteunt retries, scheduling, rate limiting en flows.

Installeer het samen met een Redis-client:

npm install bullmq ioredis

Een queue en producer opzetten

Eerst maken we een queue en voegen we een job toe vanuit je API:

// src/queues/email.queue.ts
import { Queue } from 'bullmq';

const connection = {
  host: process.env.REDIS_HOST ?? 'localhost',
  port: Number(process.env.REDIS_PORT ?? 6379),
};

export const emailQueue = new Queue('email', { connection });

In je route plaats je dan een job op de queue:

// src/routes/signup.ts
import { emailQueue } from '../queues/email.queue';

app.post('/signup', async (req, res) => {
  const user = await createUser(req.body);

  await emailQueue.add(
    'welcome',
    { userId: user.id, email: user.email },
    {
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  );

  res.status(202).json({ id: user.id });
});

De request is klaar in milliseconden. De welkomstmail verstuurt straks een worker.

De worker implementeren

Een worker is een apart proces dat naar dezelfde queue luistert:

// src/workers/email.worker.ts
import { Worker } from 'bullmq';
import { sendMail } from '../services/mailer';

const connection = {
  host: process.env.REDIS_HOST ?? 'localhost',
  port: Number(process.env.REDIS_PORT ?? 6379),
};

const worker = new Worker(
  'email',
  async (job) => {
    if (job.name === 'welcome') {
      await sendMail({
        to: job.data.email,
        template: 'welcome',
        vars: { userId: job.data.userId },
      });
    }
  },
  { connection, concurrency: 10 },
);

worker.on('completed', (job) => {
  console.log(`Job ${job.id} done`);
});

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

Start de worker als een los proces, bijvoorbeeld met node dist/workers/email.worker.js. In productie draai je dit meestal als een aparte container of service naast je API.

Retries, backoff en idempotency

Een van de grootste voordelen van een queue is automatische retry-logica. Een externe API die tijdelijk down is? BullMQ probeert het gewoon opnieuw.

Belangrijke instellingen bij add():

  • attempts: hoeveel pogingen in totaal (inclusief de eerste).
  • backoff: wachttijd tussen pogingen. Gebruik exponential voor externe services.
  • removeOnComplete / removeOnFail: hoeveel afgeronde jobs je bewaart (voor audit en debugging).

Maar retries werken alleen veilig als je jobs idempotent zijn. Een job die twee keer draait mag niet twee welkomstmails versturen of twee keer geld afschrijven. Gebruik een uniek jobId of een database-vlag om dubbele uitvoering te voorkomen:

await emailQueue.add(
  'welcome',
  { userId: user.id },
  { jobId: `welcome:${user.id}` }, // duplicate adds worden genegeerd
);

Idempotency hangt sterk samen met goede error handling op schaal. Logging, retries en observability zijn drie kanten van dezelfde medaille.

Delayed en repeatable jobs

Een queue is ook perfect voor taken in de toekomst. Een herinneringsmail over 24 uur?

await emailQueue.add(
  'reminder',
  { userId: user.id },
  { delay: 24 * 60 * 60 * 1000 },
);

Voor cron-achtige taken gebruik je repeatable jobs. Handig voor bijvoorbeeld een dagelijkse database-cleanup of een uurlijkse sync met een externe service:

await emailQueue.add(
  'daily-digest',
  {},
  { repeat: { pattern: '0 8 * * *' } }, // elke dag om 08:00
);

Zo heb je geen aparte cron-server meer nodig, je bestaande worker-infrastructuur doet het werk.

Data valideren in jobs

Jobs komen vaak van meerdere producers, en soms zelfs uit oudere releases. Wat als het datamodel is gewijzigd? Valideer je job-payload aan de start van de handler, bijvoorbeeld met Zod. In ons artikel over validatie met Zod vind je de volledige aanpak.

import { z } from 'zod';

const WelcomeSchema = z.object({
  userId: z.string().uuid(),
  email: z.string().email(),
});

const worker = new Worker('email', async (job) => {
  const data = WelcomeSchema.parse(job.data);
  // data is nu type-safe en gevalideerd
});

Workers in productie draaien

Een paar praktische richtlijnen voor een solide productie-setup:

  • Draai workers apart van je API. Een OOM in een worker mag je webserver nooit meetrekken.
  • Gebruik graceful shutdown. Sluit bij SIGTERM eerst de worker, wacht op lopende jobs en sluit dan de Redis-connectie.
  • Zet sensible concurrency. Voor I/O-werk is 10–50 vaak prima. Voor CPU-werk houd je concurrency laag en schaal je horizontaal.
  • Monitor queue-lengte. Een oplopende backlog is het eerste signaal dat je meer workers nodig hebt.
  • Scheid queues per workload. Zet snelle mails niet op dezelfde queue als zware video-encoding, de traagste job blokkeert anders de rest.

Een nette graceful shutdown:

process.on('SIGTERM', async () => {
  await worker.close();
  process.exit(0);
});

Wanneer niet voor een queue kiezen?

Een queue toevoegen betekent extra infrastructuur: Redis, monitoring, deployment, observability. Voor kleine projecten of taken die echt binnen de request kunnen, is dat overkill.

Begin klein. Een setImmediate() of een fire-and-forget Promise is soms al genoeg. Schaal naar een echte queue zodra je retries, scheduling of meerdere workers nodig hebt. En vergeet niet: een goede project structuur maakt het later veel makkelijker om een queue toe te voegen zonder alles te hoeven herschrijven.

Veelgestelde vragen

Wat zijn background jobs in Node.js?

Background jobs zijn taken die buiten de request-response cyclus draaien. Denk aan het versturen van e-mails, het genereren van rapporten of het verwerken van afbeeldingen. Ze worden op een queue geplaatst en door een worker-proces afgehandeld.

Wat is het verschil tussen een worker thread en een background worker?

Worker threads zijn onderdeel van Node.js zelf en draaien CPU-intensieve code parallel binnen hetzelfde proces. Een background worker is meestal een apart proces dat jobs uit een queue (zoals Redis) oppakt en uitvoert, los van je webserver.

Welke queue library kan ik het beste gebruiken in Node.js?

BullMQ is tegenwoordig de meest gebruikte keuze voor Node.js. Het werkt op Redis, ondersteunt retries, scheduling en rate limiting. Alternatieven zijn Agenda (MongoDB) en RabbitMQ via amqplib voor meer geavanceerde message-broker scenario's.

Wanneer heb ik eigenlijk background jobs nodig?

Zodra een taak langer duurt dan een paar honderd milliseconden of niet direct voor de gebruiker zichtbaar hoeft te zijn, hoort hij op een queue. Denk aan e-mail, PDF-generatie, webhooks, video-encoding of het aanroepen van trage externe API's.

Hoe schaal je Node.js workers in productie?

Draai workers als losse processen of containers en schaal horizontaal op basis van queue-lengte. Gebruik concurrency per worker voor I/O-taken en meerdere instances voor CPU-werk. Monitor de queue en voeg capaciteit toe voor de backlog oploopt.

Veelgestelde vragen

Klaar om digitaal te groeien?

Wij helpen Nederlandse bedrijven met webtechnologie en SEO-strategieën die écht werken. Neem vrijblijvend contact op.