Background jobs and cron: the engine behind your credits system
Renewals, expirations, notifications, and analytics all depend on jobs that run on schedule. How to design them so they don't silently fail or drift.
A credits system isn't just APIs and a database. It's also background work:
- Renew plans at the start of each period
- Expire old credits
- Send "low balance" or "renewal" notifications
- Sync usage to analytics or billing
When background jobs fail
That work runs on a schedule (cron jobs, queues, or serverless triggers). When it fails or drifts, customers see:
- Wrong balances
- Missed renewals
- Duplicate charges
This article covers what has to run in the background, how to make it reliable, and how to avoid the "why didn't it renew?" class of bugs that erode trust in your product.
What actually has to run
Typical background work in a credits and plans system includes:
1. Renewals
At the start of each billing period, grant the plan allowance (e.g., 10,000 credits) to the account. This requires a job that:
- Finds "accounts whose next renewal time is <= now"
- Applies the grant
- Sets the next renewal time
2. Expirations
Credits (or grants) that have an expiry date must be zeroed or marked expired when that date passes. A job:
- Runs periodically
- Finds expired grants or balances
- Applies the expiration logic (e.g., reduce balance, mark grant as expired)
3. Scheduled plan changes
Trial-to-paid, downgrade at period end, or scheduled upgrades. A job (or a generic "scheduled actions" worker):
- Finds due transitions
- Applies them
4. Notifications
"Your balance is low," "Your plan renewed," "Your trial ends in 3 days." These can be triggered by a job that:
- Scans for conditions (e.g., balance < threshold, renewal in 72 hours)
- Sends emails or in-app messages
5. Sync to billing or analytics
Push usage summaries, grant events, or balance snapshots to a data warehouse or billing provider. Often a daily or hourly job that exports and uploads.
Why background jobs are necessary
None of this can be done synchronously in the request path without making API responses slow and fragile. You need a scheduling and execution layer that runs these jobs at the right time and handles failures.
Cron and its limits
The simplest approach is cron: a script that runs every hour (or day) and does "renewals," "expirations," etc.
The problems with cron
1. Granularity If you run renewals once per day at 00:00 UTC, a customer whose renewal was at 00:01 doesn't get renewed until 24 hours later. You either:
- Run more frequently (e.g., every 5 minutes)
- Use a more precise scheduler that runs jobs at per-entity times
2. Single point of failure If the cron host is down or the job crashes, nothing runs until the next invocation.
3. No built-in retry A failed run might leave some accounts renewed and others not. On the next run you need idempotency so you don't double-grant.
4. Drift If the job takes longer than the interval (e.g., 1-hour job, 1-hour cron), you get overlapping runs or skipped runs depending on how you implement it.
When cron is good enough
Cron is fine for "run every night" batch work.
When you need something better
For time-sensitive renewals and expirations you often need something more robust:
- A queue with per-item scheduling
- A dedicated scheduler (e.g., job table with "run_at," worker picks due jobs)
Idempotency and retries
Renewal and expiration logic must be idempotent. If the job runs twice for the same account and period (e.g., retry after a timeout), it must not grant twice or expire twice.
Implementing idempotency
For renewals: "Ensure this account has exactly one grant for this period"
- Use
INSERT IF NOT EXISTS - Or check a "renewal_done" flag
For expirations: "Mark these grants as expired and adjust balance once"
- Only process grants not already marked expired
The payoff
With idempotency, you can safely:
- Retry failed jobs
- Run catch-up after an outage
Without it, a single retry can double-credit or double-charge.
Partial failure and consistency
A job that processes 10,000 accounts may fail after 3,000. You need a strategy:
Three key practices
1. Process in small batches Commit after each batch, so you can resume from "last processed" or "next batch."
2. Track progress E.g., "renewals processed up to renewal_time X" so the next run knows where to start.
3. Alert on failure So someone notices and can fix or re-run.
What not to do
Don't use one big transaction: A failure at the end rolls back everything and you've made no progress.
Don't skip checkpointing: A failure forces you to re-do work and risks double application if not idempotent.
Design for partial success and resumability
Observability
You need to know: did the job run? how many items did it process? did it error? Simple logging (counts, last processed id or time, errors) plus alerts (job didn't run, error rate spiked) are the minimum. For credits, add business-level checks: "renewals due today: N, renewals applied: M" and alert if M is far from N. That catches logic bugs (e.g. wrong query so no accounts found) as well as infra failures.
Summary and recommendations
Background jobs are the engine of a credits system. To keep them reliable:
Key practices
- Identify all time-based work (renewals, expirations, scheduled transitions, notifications, sync) and assign each to a job with a clear schedule or trigger
- Prefer a scheduler or queue with per-item or fine-grained timing for renewals and expirations, so you're not limited to "once per hour" cron
- Make jobs idempotent so retries and catch-up runs don't double-apply
- Design for partial failure (batches, checkpoints, resume) and alert on missed runs or anomalies
The operational burden
If you're building this in-house, budget for:
- Monitoring
- Runbooks
- Fixing "why didn't it renew?" incidents
Or delegate it
If you're evaluating a credits API, check that it runs renewals and expirations for you. Then you don't own the cron layer at all.
credits.dev handles plan scheduling, renewals, and credit lifecycle so you don't have to build and maintain the background engine yourself.