When you first add background jobs, payloads are an afterthought. You enqueue whatever the handler needs, it runs a second later, done.
Then you start scheduling things for later: a trial-ending reminder in 14 days, a renewal notice in a year, a follow-up email in 30 days. Now that payload has to survive every deploy, every refactor and every data change between now and then. That's when the shortcuts from the early days start to hurt.
Here's what I'd do differently if I were designing payloads from scratch.
1. Store references, not snapshots
The most common early mistake is enqueueing a serialised object:
{ "user": { "id": 42, "email": "old@example.com", "plan": "pro", "name": "Sam" } }
By the time the job runs, the user may have changed their email, downgraded, or deleted their account. The job runs confidently on stale data, and sends the reminder to an address the user removed.
What I'd do: store identifiers, and load current state when the job runs.
{ "user_id": 42, "subscription_id": 981 }
There's one exception: when you genuinely need the value as it was when the job was scheduled, like the price quoted at checkout or the content of a message the user wrote. Then store it deliberately, and name it so it's obviously a snapshot: quoted_price_cents, not price.
2. Version every payload from day one
Your handler code will change. Jobs scheduled under the old shape will still be in the queue when the new code deploys.
What I'd do: include a version field in every payload, and make handlers accept the previous version for at least as long as your longest scheduled delay.
{ "v": 2, "user_id": 42, "reminder_kind": "trial_ending" }
function parse(raw: unknown): ReminderV2 {
const p = raw as { v?: number };
if (p.v === 2) return p as ReminderV2;
if (!p.v || p.v === 1) return upgradeV1(p as ReminderV1);
throw new Error(`Unknown payload version ${p.v}`);
}
It costs one field. It saves the deploy where every long-delay job starts failing because a property was renamed.
3. Don't serialise language-specific objects
Some job libraries make it easy to enqueue a class instance, a pickled object or a framework-specific model. It works until you rename the class, move the module, upgrade the serialiser, or want a service in a different language to read the queue.
What I'd do: plain JSON with primitive types only. Dates as ISO 8601 strings with an offset, or as epoch milliseconds, never as language date objects. Money as integer minor units with a currency code.
4. Re-check the reason the job exists
A job scheduled 30 days ago captured an intent: "remind this user their trial ends". When it runs, that intent may no longer apply. They upgraded. They cancelled. Support extended their trial.
What I'd do: every delayed job's handler starts by checking whether it should still run, using current data:
const sub = await subs.get(job.subscription_id);
if (!sub || sub.status !== "trialing" || sub.trialEndsAt > job.expected_trial_end) {
return skip("trial no longer ending as scheduled");
}
Store the condition you expected (expected_trial_end) in the payload so the handler can detect that things changed. And log skips with a reason; a spike in skips is useful information.
5. Give each job a stable identity
If the same reminder can be scheduled twice, because of a retry, a double click, or a bug, it eventually will be.
What I'd do: derive a deterministic key from what the job means, and use it for de-duplication on enqueue and idempotency on execution: trial-ending:sub_981:2026-12-01. If your queue supports unique job IDs, use the key as the ID. If not, enforce it in your own table.
6. Keep payloads small and free of secrets
Payloads end up in queue storage, logs, dead-letter queues, admin dashboards and error trackers. Anything in them is effectively widely readable and long-lived.
What I'd do: no access tokens, passwords, full card numbers or sensitive personal data in payloads. Look them up at run time. Keep payloads small enough that they're comfortable to log in full, because when something goes wrong, you'll want to.
7. Record why and by whom it was scheduled
Months later, someone asks "why did this customer get this email?". The payload alone often can't answer.
What I'd do: add a small metadata block:
{
"v": 2,
"user_id": 42,
"meta": {
"scheduled_by": "billing.trial_started",
"scheduled_at": "2026-11-17T10:04:00Z",
"correlation_id": "req_7f3a"
}
}
This makes debugging, auditing and support conversations dramatically easier, and it's useful for finding every job created by a buggy code path so you can cancel them.
8. Plan for cancelling and rescheduling
If the only way to cancel a scheduled job is to find it in the queue by scanning payloads, you'll end up writing that scan during an incident.
What I'd do: keep a row in your own database for each long-delay job (key, kind, subject ID, run-at, status), and treat the queue as the delivery mechanism. Cancelling becomes an update; rescheduling becomes an update plus a new enqueue; the handler checks status before running.
The short version
- IDs over snapshots, unless the snapshot is the point.
- A version field, and handlers that accept old versions.
- Plain JSON, explicit units and time zones.
- Re-check intent at run time.
- Deterministic keys for de-duplication.
- No secrets, small enough to log.
- Metadata about who scheduled it and why.
- A record you can cancel or reschedule without searching the queue.
Long-delay jobs are where these choices matter most, and they're the jobs delay.run is built to hold reliably.
Related reading
- Cron in Docker FAQ: 11 questions from everyone whose job worked locally and does nothing in the container
- Don't use setTimeout for anything that matters: where delayed jobs should actually live
- Make scheduled job handlers idempotent: safe to run twice
- The transactional outbox, explained: how to save to the database and enqueue a job without losing either
What's the oldest job sitting in your queue right now, and would its payload still make sense to today's code?
Top comments (0)