← Blog

Automation

Durable execution: Inngest, Trigger.dev en BullMQ bij 1M

Een Amersfoorts bureau met 31 mensen draait 7.800 klantrapportages per week. We bouwden de queue opnieuw op Inngest, Trigger.dev en BullMQ. Dit kost elk op schaal.

Jacob Molkenboer· Oprichter · A Brand New Company· 11 jun 2026· 7 min
Drie messing relais op rij op ivoorpapier, groene indexkaart onder het middelste, papieren strookje en rode lakzegel.

Het is 23:14 op een vrijdagavond in Amersfoort. Een accountmanager pingt het #alerts-kanaal van de studio: "Is het weekrapport voor de Volendamse veerklant verstuurd? Ze mailden om te vragen." Niet dus. De cron-job stierf om 21:40, sleurde vierhonderd PDF's mee, en de worker-box reboot sindsdien in een loop. Dit is het soort avond dat een team op zoek doet gaan naar durable execution.

Het bureau in kwestie: eenendertig mensen, zestig actieve retainerklanten, ongeveer 7.800 klantrapportages per week getrokken uit Meta, LinkedIn Ads, GA4, Search Console, Mailchimp en een zelfgebouwde PostgreSQL-warehouse. Rapporten worden samengesteld tot een PDF, gespiegeld naar Notion en gemaild naar de klantlead. De eerste versie was een Node-script achter cron. Werkte prima bij vijftig klanten. Bij zestig begonnen de failure modes vrijdagavonden op te eisen.

Ze vroegen ons de queue opnieuw te bouwen op iets dat retries doet, dedupliceert, en de enige in-house developer van het bureau zaterdagochtend iets te geven om naar te kijken dat geen 600-regelige stack trace is. We benchten drie opties end-to-end: Inngest, Trigger.dev en een handgebouwde BullMQ-stack op Upstash Redis. Zo verhouden ze zich onder belasting.

De vorm van het werk

Elk weekrapport draait ruwweg twaalf stappen: het verversen van source-tokens, zes API-pulls (gepagineerd, rate-limited), drie transform-en-opslagstappen, een Puppeteer-render naar PDF, een Mailgun-overdracht. Verspreid over zestig klanten is dat ongeveer 93.000 step-runs per week, of ongeveer één miljoen step-runs per tien weken. De meeste stappen falen af en toe. Meta rate-limit twee of drie keer per dag. De marketing-API van LinkedIn gooit 504's in clusters. Tokens verlopen op maandag om redenen die niemand begrijpt.

Elk van de drie opties kan dit volume technisch aan. De vraag is wat er gebeurt als stappen falen om 23:14 op een vrijdag.

Retry-semantiek

Inngest is de meest opinionated van de drie. Elke stap wordt verpakt in step.run(), en Inngest behandelt elke stap als een idempotente unit of work. Als een stap throwt, retryt hij op zijn eigen schema met exponential backoff. Inputs voor de volgende stap zijn de outputs van de vorige, dus een retry van stap 7 herhaalt stappen 1 tot en met 6 niet.

// Inngest: step-level durability is the default
inngest.createFunction(
  { id: "weekly-report", retries: 4 },
  { event: "report/weekly.requested" },
  async ({ event, step }) => {
    const tokens = await step.run("refresh-tokens", () =>
      refreshTokens(event.data.clientId)
    );
    const meta = await step.run("pull-meta", () =>
      pullMeta(tokens.meta)
    );
    const pdf = await step.run("render-pdf", () =>
      renderPdf(meta /* plus the other source results */)
    );
    await step.run("send-email", () => sendEmail(pdf));
  }
);

Trigger.dev v3 komt op hetzelfde resultaat uit vanuit een andere houding. Tasks zijn first-class objecten en je componeert ze met await. Hun durable runtime checkpoint state tussen awaits, dus een crash halverwege resumeet vanaf het laatste checkpoint en niet vanaf het begin. Retries zijn per task configureerbaar, met dezelfde exponential- en jitter-knoppen.

// Trigger.dev v3: await is the boundary
import { task } from "@trigger.dev/sdk/v3";

export const weeklyReport = task({
  id: "weekly-report",
  retry: { maxAttempts: 4, factor: 2, minTimeoutInMs: 1000 },
  run: async (payload: { clientId: string }) => {
    const tokens = await refreshTokens.triggerAndWait(payload);
    const meta = await pullMeta.triggerAndWait({ token: tokens.meta });
    const pdf = await renderPdf.triggerAndWait({ /* ... */ });
    await sendEmail.triggerAndWait({ pdf });
  },
});

BullMQ laat de durability aan jou over. Retries en backoff zijn first-class op de job-options, maar step-level checkpointing zit er niet in. Haalt je processor-functie eerst Meta op, schrijft daarna naar Postgres, en crasht hij dan voordat de render klaar is, dan herhaalt de retry de Meta-pull en de Postgres-write. Of je splitst elke stap in zijn eigen queue en ketent ze, of je bouwt idempotency handmatig in elke side effect.

// BullMQ: per-job retry is easy, step-level durability is your problem
new Queue("weekly-report").add(
  "client-42",
  { clientId: "42" },
  {
    attempts: 4,
    backoff: { type: "exponential", delay: 2000 },
    jobId: `weekly-report:${weekOf}:${clientId}`, // idempotency you own
  }
);
Let op

Kies je voor BullMQ, dan is je retry-story alleen zo goed als je idempotency-keys. We hebben dit jaar twee productie-incidenten opgeruimd waarbij een "retry" dezelfde Mailchimp-campagne twee keer verstuurde omdat de dedupe-key stiekem een timestamp bevatte.

Debuggen om 23:00

Dit is de regel die de meeste adoption-stories beslist, en de regel die kopers onderschatten in spreadsheets. Storingen komen voor. Wanneer dat gebeurt, heeft iemand een scherm nodig dat laat zien wat er misging, met de juiste inputs om opnieuw af te spelen.

Het dashboard van Inngest toont per stap input, output en timing, met één enkele "rerun this step"-knop. Voor een in-house developer die de originele code niet heeft geschreven, telt dat. Hij opent één URL, ziet de gefaalde stap, kijkt naar de payload, fixt de functie en herstart de stap vanuit het dashboard zonder opnieuw te deployen.

De run-view van Trigger.dev is qua geest vergelijkbaar, met een rustigere timeline en een ingebedde log per task. Replay zit per run, niet per stap. Voor onze pijplijn is dat meestal prima: de stappen zijn goedkoop om opnieuw uit te voeren als de writes idempotent zijn.

BullMQ levert niets. Bull Board geeft je gratis een nette queue-UI, maar je zit nog steeds log-regels op de worker-box uit te lezen om te zien waarom een job faalde. Draai je op Kubernetes met een serieuze Grafana-setup, dan is dat prima. Heb je één developer en een Hetzner-box, dan niet.

De rekening bij één miljoen step-runs

Prijzen zijn het stuk van zo'n vergelijking dat het slechtst veroudert, dus check de leveranciers-pagina's, niet deze alinea. Stand juni 2026:

  • Inngest rekent per step-run boven een ruim free tier, met betaalde plannen die meeschalen met concurrency. De pricing page van Inngest is de bron.
  • Trigger.dev rekent in de cloud per run en per compute-seconde, en nul euro als je self-host op je eigen Postgres en worker-fleet. Zie hun pricing page.
  • BullMQ zelf is gratis. Je rekening is Redis (Upstash, Redis Cloud, of self-hosted) plus een worker-box. Voor dit bureau kwam dat neer op een klein managed Redis-plan en een bescheiden VM.

Bij één miljoen step-runs per maand is de handgebouwde stack veruit het goedkoopst. De valkuil is dat je er ook een pieper bij koopt. De rekeningen van Inngest en Trigger.dev zien er op zichzelf hoog uit. Naast het avonduurtarief van een senior developer zien ze er redelijk uit.

Wat we kozen en waarom

Voor dit bureau kozen we Trigger.dev. Drie redenen. De in-house developer leest TypeScript sneller dan dashboards, en de await-stijl-API mapte schoon op het bestaande script met heel weinig herschrijfwerk. De self-host-escape gaf de operations lead het vertrouwen om de verlenging te tekenen. En het checkpoint-gedrag matchte de failure modes die we daadwerkelijk zagen: half rapport gegenereerd, dan een Meta 504, dan een schone resume.

Inngest was een verdedigbare tweede keuze geweest. Was de workload kleiner geweest of vooral opgebouwd uit AI-agent-stappen in plaats van rapportagestappen, dan hadden we daarvoor gekozen om de per-step retry-ergonomie. BullMQ was de juiste keuze geweest als het bureau twee infra-mensen had gehad in plaats van één developer.

Eén aangrenzende noot is het noemen waard: durable execution is ook wat een hobby-AI-agent scheidt van een productiewaardige. De recente verhalen over agents die zich misdragen op developer-laptops en binnen Linux-distributies lezen voor een deel als verhalen over tools zonder idempotency en zonder replay. Of je nu rapporten of agents draait, de saaie queue-primitieven zijn het vangnet.

De vijf-minuten-audit

Toen we deze rapportagepijplijn voor het Amersfoortse bureau bouwden, was wat we laat tegenkwamen niet retries. Het was dat twee van de API-pulls niet-deterministische ordering teruggaven, wat een downstream-diff brak en de dedupe zinloos maakte. We losten het op door bij de boundary te sorteren en per rij een content-hash op te slaan, zodat "is er deze week iets veranderd" een vergelijking van één regel werd. De les is breder: kies een durable runner en besteed de gewonnen uren aan het idempotent maken van je side effects. Wil je meedenken welke runner bij jouw team past, dan is dat het soort procesautomatisering waar wij ons mee bezighouden.

Evalueer je dit nu, dan is de vijf-minuten-test simpel: open een bestaande gefaalde job, tel de regels code tussen de storing en de rerun, en vraag je af of dat aantal groter of kleiner wordt naarmate het team groeit. De juiste runner is degene die het krimpt.

Kern

Kies de durable runner die de afstand tussen een storing en een rerun met één klik verkleint. Kosten zijn de makkelijke as. On-call-uren zijn de moeilijke.

FAQ

Heb ik durable execution nodig als mijn cron-jobs vandaag werken?

Zijn je jobs idempotent, laag in volume en met de hand opnieuw te draaien, dan nee. Heb je multi-step-runs, gedeeltelijke storingen of honderden downstream-mails per job, dan wel.

Kan ik Trigger.dev of Inngest zelf hosten?

Trigger.dev heeft een gedocumenteerd self-host-pad op Postgres plus je eigen worker-fleet. Inngest is vooral een hosted product, met een lokale dev-server voor ontwikkeling en testen.

Waarom geen Temporal of Apache Burr?

Temporal is uitstekend voor langlopende, complexe workflows, maar zwaarder om te draaien. Burr richt zich op state machines voor AI-agents. Voor rapportagejobs van twaalf stappen zijn de drie die we vergeleken hebben qua maat passend.

Hoe maak ik BullMQ-retries veilig?

Ontwerp elke side effect idempotent: stabiele jobId-keys zonder timestamps, conditionele upserts in Postgres, dedupe-headers op uitgaande mail, en geketende queues in plaats van één dikke processor.

automationprocess automationarchitecturetoolingintegrationsworkflow

Iets bouwen?

Start een project