Why Midnight Billing Cron Jobs Break at Scale
Why midnight subscription billing cron jobs collapse at scale, and how to fix them in Laravel with queues, idempotency, and staggered renewal windows.
A straightforward Laravel cron job that synchronously charges every expiring subscription at midnight works fine with a handful of test users, but it's a time bomb tied directly to growth. Four hidden issues surface at scale: all renewals clustering at the same timestamp, synchronous API calls inside a loop multiplying runtime by subscriber count, Stripe and database rate limits throttling requests, and missing idempotency turning retries into duplicate charges.
The fix isn't a bigger server — it's rethinking three things: staggering renewal attempts across a window instead of firing them all at expiration (using separate renewal_due_at and ends_at fields), having the scheduler only dispatch queued jobs per subscription rather than charging inline, and making each renewal job idempotent by checking actual state (whether renewal_due_at is already future, or auto_renew already disabled) before attempting a charge. Laravel 13's Queue::route() helps centralize queue/connection configuration for this pattern.
The broader lesson is treating billing as a distributed, queue-driven, idempotent sequence rather than a simple loop. Trusting webhooks as the source of truth for successful charges, preserving every payment attempt as its own record instead of overwriting state, and relying on retry/backoff in queue workers together eliminate the two most common billing bugs: duplicate charges and false-positive activations.