Process automation
Process automation die ontspoort: de €4.100 polling-bug
Een logistieke SaaS uit Utrecht met 19 mensen verloor bijna een jaar marge aan een poll-loop van 200ms. De agent deed zijn werk. De cloudrekening deed de schade.

De rekening die er niet had moeten zijn
De CFO van een logistieke SaaS uit Utrecht met 19 mensen stuurde ons om 16:47 op een donderdag één screenshot door. Hun managed Postgres + Redis-rekening bij hun cloudprovider was van €380 in februari gegaan naar €4.100 in mei. Geen nieuwe klanten. Geen nieuwe features in productie. Geen infrastructuurmigratie. Gewoon drie maanden samengestelde kosten van één enkele process automation-worker die ze niet konden verklaren.
De ops-lead had een theorie: "Iets wat we hebben gebouwd hamert te hard op de database." Ze had gelijk, maar bij toeval. De kostendrijver was niet de database. Het was een Redis-queue, en het proces in kwestie was iets wat haar eigen team in oktober had aangevraagd.
Wat de agent moest doen
Het bedrijf draait een platform voor vrachtroutering. Hun klanten (vooral middelgrote 3PL's) sturen EDI-achtige orderdocumenten binnen via SFTP, een HTTPS-endpoint en steeds vaker via een partner-API. Een kleine worker pikt die op, valideert ze, verrijkt ze met carrier-metadata en zet ze als jobs op Redis voor de routing engine.
Wij hadden de intake-worker in oktober 2025 voor ze gebouwd. De opdracht was simpel: hou de inbox leeg. Zodra een document onder orders:incoming op Redis landt, pop het, valideer, verrijk, push naar orders:routed. Hun volume lag toen op ongeveer 1.200 documenten per dag, met pieken rond 09:00 Amsterdam en weer rond 14:00. Gemiddelde idle-tijd tussen jobs: ongeveer 45 seconden.
Dat getal is het cijfer dat ertoe doet, en daar komen we op terug.
De beslissing van 200ms
De worker was geschreven in Node.js. De oorspronkelijke auteur (een van onze engineers, op dat moment in Chiang Mai) maakte een verdedigbare keuze: een polling-loop met een tick van 200ms, omdat de routing engine jobs "instant" moest laten voelen tijdens demo's. De relevante code zag er zo uit:
while (running) {
const job = await redis.lpop('orders:incoming');
if (job) {
await process(job);
continue;
}
await sleep(200);
}In oktober, met één worker en 1.200 jobs/dag, was dit prima. De Redis-instance draaide op een starter-plan. De kosten waren onzichtbaar.
In november tekende het bedrijf een partnerdeal die het dagelijkse volume ongeveer verdubbelde. Logisch. In januari zetten ze een staging-omgeving op die productie spiegelde, en iemand vergat daar de worker-concurrency te verlagen. In februari voegde het routing-team twee extra worker-instances toe "voor wat speling". Tegen maart had een externe contractor een vijfde worker toegevoegd zonder het door te geven.
Elke worker deed in idle vijf LPOP-calls per seconde. Vijf workers, vier omgevingen (prod, staging, twee preview-branches), 20 worker-processen in totaal. Dat zijn 100 LPOP-calls per seconde, in idle. 8,64 miljoen calls per dag. 260 miljoen calls per maand. Tegen een managed Redis-instance die deels op operaties en deels op egress wordt afgerekend, met metrics die naar de observability-tier van de provider werden gestuurd (ook per event afgerekend), was de kostencurve exponentieel geworden zonder dat iemand het doorhad.
De echte kostendrijver lezen
Toen we de billing-breakdown opvroegen, was de verrassing niet waar het geld heen ging. De Redis-regel was maar €620 van de €4.100. Postgres was €310. De echte schade zat op drie plekken waar niemand had gekeken.
De eerste was hun managed observability-tier. Elk Redis-commando werd als metric-event verstuurd omdat iemand tijdens een incident in februari "verbose mode" had aangezet en die nooit had uitgezet. Dat alleen was al €1.400 per maand.
De tweede was egress tussen de workers (in eu-west-1) en de Redis-instance (in eu-central-1). Een region-mismatch op een strakke poll-loop is een trage belasting die zich opstapelt. €890 per maand.
De derde was de autoscaler. Hun Postgres connection pool was gedimensioneerd om pieken op te vangen, en de polling-workers hielden elk een idle connectie open. Het billing-model van de provider rekende voor "active connection hours" boven een baseline. €560 per maand, als aparte regel die niemand las omdat hij in oktober nog €40 was.
De daadwerkelijke databasebelasting was bijna niets. De kosten zaten volledig in het leidingwerk.
De fix van drie regels
De fix was niet architectonisch. Er was geen nieuw queueing-systeem, geen refactor en geen vergadering voor nodig. Het waren drie regels:
while (running) {
const job = await redis.brpop('orders:incoming', 30);
if (!job) continue;
await process(job[1]);
}BRPOP is een blocking pop. Hij wacht tot 30 seconden op een job, en geeft dan terug. Komt er niets, dan komt nil terug en loopen we opnieuw. Tijdens het wachten doet de Redis-instance geen werk voor die client. Het Node.js-proces houdt één connectie open, idle, en gebruikt effectief nul CPU. De semantiek staat gedocumenteerd in de Redis BRPOP-referentie en is in tien jaar niet veranderd.
Nadat we dit op een dinsdagmiddag naar alle omgevingen hadden uitgerold, daalde idle LPOP-verkeer van 100 ops/sec naar ongeveer 0,03 ops/sec (één BRPOP die elke 30 seconden leeg terugkomt, per worker). De verbose-metrics-regel verdween de volgende ochtend. De egress-regel daalde de week erna met 94%. De connection-hour-teller van de autoscaler zakte binnen één billing-cyclus onder de drempel.
De rekening van mei was €312. De prognose voor juni is €290.
Als je een worker hebt die strak pollt tegen een managed queue (Redis, SQS, RabbitMQ, Postgres FOR UPDATE SKIP LOCKED), dan zit de kostendrijver bijna nooit in de database zelf. Hij zit in de observability-tier, in de egress, en in de connection-hour-regel die je nooit hebt gelezen.
Wat het dashboard miste
Het rare aan dit verhaal is dat het team monitoring had. Ze hadden Grafana, ze hadden Sentry, ze hadden een Slack-alert voor "Redis latency > 50ms". Niets daarvan ging af. De queue was gezond. De workers waren gezond. De latency was eerder abnormaal goed, omdat de workers zo gretig popten dat geen job ooit langer dan een paar milliseconden in de queue zat.
Wat niet werd gemeten, was idle work. Er bestaat geen dashboardwidget die "ops per seconde die het systeem doet terwijl het niks nuttigs doet" laat zien. Er is een billing-dashboard, maar billing-dashboards worden door finance gelezen, en finance kijkt er één keer per maand naar nadat de factuur is binnengekomen.
De vertraging tussen "we hebben de verkeerde beslissing genomen" (oktober) en "we hebben de verkeerde beslissing gezien" (mei) was zeven maanden. Dat is ongeveer de halfwaardetijd van "we kijken er volgende sprint naar" in een klein team.
Een runbook voor elke worker die een queue raakt
Daarna hebben we voor onszelf een korte runbook geschreven, en die checken we sindsdien in bij elk project dat een automation-worker oplevert. Het principe is simpel: als er geen geschreven contract is voor hoe de worker zich moet gedragen wanneer hij idle is, dan gaat hij zich uiteindelijk slecht gedragen en weet niemand meer wiens beslissing dat was.
De runbook is kort:
- Verkies blocking primitives boven polling.
BRPOP,BLPOP,XREAD BLOCK, Postgres LISTEN/NOTIFY, SQS long-polling, RabbitMQbasic.consume. Lees de documentatie van je queue en zoek de blocking call. - Moet je pollen, dan exponential backoff. Start op 100ms, verdubbel bij leeg, cap op 30 seconden. Reset bij een succesvolle pop.
- Eén omgeving, één config. De aanpak "we kopiëren productie wel even" naar staging is hoe een vloot van vijf workers er twintig wordt.
- Bill-alerts per regelitem, niet op het totaal. Een 3x sprong op één regel pakt dit in week één, niet in maand zeven.
- Behandel de observability-tier als code waarvoor je betaalt. Metrics zijn events. Events zijn geld. Verbose-mode-knoppen hebben een houdbaarheidsdatum nodig.
Niets hiervan is nieuw. Niets is verrassend zodra je een keer gebeten bent. Maar het is, in onze ervaring, de meest voorkomende oorzaak van "onze cloudrekening is stilletjes ontploft" bij kleine SaaS-teams die automation-workers in productie hebben.
Het kleinste wat je vandaag kunt doen
Toen we de intake-worker voor het Utrechtse team opnieuw bouwden (een process automation-opdracht die begon als een inbox-zero-briefing en uitgroeide tot een kleine vloot agents), liepen we ertegenaan dat de oorspronkelijke beslissing van 200ms in elke code review die hij ooit passeerde verstandig had geleken. We hebben het uiteindelijk opgelost door de runbook hierboven te schrijven en vlak naast de worker-source te pinnen, zodat de volgende engineer er niet langs kan kijken.
De audit van vijf minuten, als je hem vandaag wilt draaien: open je cloud billing-dashboard, filter op de laatste 90 dagen, en sorteer regelitems op grootste absolute verandering. Het antwoord staat in de eerste drie regels. Het is vrijwel zeker niet de regel die je verwacht.
Kern
Een strakke polling-loop blaast je database niet op. Hij blaast je observability-rekening, je egress en je connection-hours op, en hij doet dat maandenlang in stilte.
FAQ
Waarom kost elke 200ms pollen zoveel als de database er zelf nauwelijks iets van merkt?
De kosten stapelen zich op in observability-events (elk commando als metric verstuurd), cross-region egress en facturering per connection-hour. De databasebelasting is vaak het kleinste regelitem.
Wanneer gebruik ik BRPOP in plaats van LPOP in een polling-loop?
Voor elke worker die een Redis-list als queue leest met idle-periodes. BRPOP blokkeert server-side tot er een job binnenkomt, dus zowel client als Redis doen nul werk tijdens het wachten.
Hoe vang ik dit soort sluipende kosten eerder af?
Zet alerts per regelitem op je cloudrekening, niet alleen een totaaldrempel. Een 3x sprong op één regel pakt het probleem in de eerste billing-cyclus; een 20% sprong op het totaal duurt maanden voor het verdacht oogt.
Speelt hetzelfde risico bij SQS of RabbitMQ?
Ja. SQS zonder long polling stapelt razendsnel empty-receive-kosten op. RabbitMQ heeft een ander billing-model, maar een strakke polling-consumer verspilt nog steeds connecties en metrics. Gebruik de blocking primitive van je queue.