For most mobile apps, a job queue in PostgreSQL is enough for reminders and background work. Store jobs in a table, claim them with SELECT ... FOR UPDATE SKIP LOCKED so multiple workers never take the same row, make each job idempotent so a retry is harmless, retry failures with exponential backoff, store user schedules as a local time plus an IANA time zone name, and watch queue depth and job age. You can add a dedicated queue system later if you outgrow this.
What background work does a mobile backend need?
A mobile app does not run when the user’s phone is in a pocket, so anything that must happen on time has to run on the server. Common examples:
- Daily or weekly reminders, such as “log your workout”.
- Push notifications triggered by events, like a comment or a shipped order.
- Subscription housekeeping: expiry checks, grace-period reminders, reconciliation with the store.
- Data work: sending a welcome email, resizing an upload, exporting a report.
- Cleanup: deleting expired tokens, old sessions and soft-deleted records.
These jobs share the same needs. They must run at least once, not run too often, survive crashes and deploys, and be visible when they fail.
Why start with a Postgres queue?
If your data is already in PostgreSQL, a queue in the same database has real advantages. A job can be created in the same transaction as the data that caused it. If the transaction rolls back, the job never exists. If it commits, the job exists. With a separate broker you must handle the gap between “saved the row” and “published the message”, which is a classic source of lost or duplicate work.
Other benefits: one less system to run, one backup story, and jobs you can inspect with plain SQL. The limits are throughput and polling overhead. A Postgres queue handles a large number of jobs per second on reasonable hardware, but if you need very high fan-out or sub-millisecond latency you may want a dedicated system. Most mobile apps never reach that point, and it is easier to move later if your job interface is a thin layer.
A simple schema:
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
kind text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}',
idempotency_key text UNIQUE,
run_at timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0,
max_attempts int NOT NULL DEFAULT 8,
status text NOT NULL DEFAULT 'queued',
last_error text,
locked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX jobs_ready_idx ON jobs (run_at) WHERE status = 'queued';
The partial index keeps lookups fast, because it only covers rows that are waiting.
How does SKIP LOCKED work?
With several workers polling the same table, you need each job claimed by exactly one of them. A naive SELECT then UPDATE has a race: two workers select the same row. Locking the row with FOR UPDATE fixes correctness but makes workers wait on each other. SKIP LOCKED tells PostgreSQL to skip rows that another transaction has locked (see the SELECT locking clause), so each worker moves on to the next available job.
WITH next AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
LIMIT 10
FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'running', locked_at = now(), attempts = attempts + 1
FROM next
WHERE jobs.id = next.id
RETURNING jobs.*;
This claims up to ten jobs in one statement. The claim commits quickly, then the worker does the real work outside the transaction, so no long-held lock blocks other workers.
Two details matter. First, a worker can crash after claiming a job, leaving it stuck in running. Add a reaper that periodically moves jobs with an old locked_at back to queued, or use a lease: a locked_until timestamp that a healthy worker extends. Second, SKIP LOCKED gives you an inconsistent view by design, so use it only for queue-like access, not for general reads.
Why do jobs need idempotency keys?
Queues deliver at least once. A worker may finish the work and crash before recording success, so the job runs again. Retries, reaper requeues and duplicate enqueues all produce repeats. The safe response is to make running a job twice equal to running it once.
There are two layers.
Enqueue-side deduplication. Give each logical job a stable key and enforce it with a unique constraint. Enqueueing “daily reminder for user 42 on 2026-10-27” twice should create one row:
INSERT INTO jobs (kind, payload, idempotency_key, run_at)
VALUES ('send_reminder', '{"user_id": 42}', 'reminder:42:2026-10-27', $1)
ON CONFLICT (idempotency_key) DO NOTHING;
Effect-side deduplication. The job’s own side effects must tolerate a repeat. If the job sends a push notification, record the send keyed by the job ID before or as you send, and check it first. If it calls an external API that supports idempotency keys, pass one derived from the job. If it updates a row, write the update so repeating it changes nothing, such as setting a status rather than incrementing a counter.
Perfect exactly-once delivery across a network is not achievable in general. Aim for at-least-once delivery plus idempotent effects, which gives the same result in practice.
How should retries and backoff work?
Failures come in two kinds. Transient failures, like a timeout or a rate limit from a provider, are worth retrying. Permanent failures, like an invalid device token or a malformed payload, are not. Make your job code distinguish them: throw a retryable error for the first and mark the job failed or dead for the second.
For retries, use exponential backoff with jitter. Waiting longer after each failure gives a struggling dependency room to recover, and randomness stops all failed jobs from retrying at the same instant.
function nextRunAt(attempts: number, now: Date): Date {
const baseSeconds = 30;
const capSeconds = 6 * 60 * 60;
const delay = Math.min(capSeconds, baseSeconds * 2 ** attempts);
const jittered = delay * (0.5 + Math.random() * 0.5);
return new Date(now.getTime() + jittered * 1000);
}
Set a maximum attempt count. After the last attempt, move the job to a dead status and keep it, with its error, so a person can inspect it and requeue it. Never silently drop failed jobs. A dead-letter view is also your best record of what is going wrong.
Respect provider hints. If an API returns a Retry-After header, use it over your own schedule.
How do you schedule reminders in a user’s local time?
“Remind me at 8 AM” means 8 AM wherever the user is, and that changes with time zones and daylight saving time. Store the schedule as three pieces of data:
- A local time of day, such as
08:00. - An IANA time zone name, such as
America/Chicago, not a fixed offset like-06:00. An offset is wrong for half the year in places that observe DST. - The recurrence rule, such as daily or weekdays.
Compute the next UTC run time from these each time, using a maintained time zone library rather than hand-written arithmetic. After each run, compute the next occurrence and enqueue it. Storing only the next UTC instant and adding 24 hours each day drifts by an hour across a DST change.
Know the edge cases:
- Spring forward. A local time that does not exist, such as 02:30 on the day clocks skip from 02:00 to 03:00. Decide the rule: run at the next valid time, which is usually the right choice.
- Fall back. A local time that happens twice. Decide whether to run on the first occurrence or the second, and run it once.
- Travel. If the user flies to another zone, should the reminder follow them? Update the stored zone when the app reports a new device time zone, and decide whether that changes the schedule or only future ones.
- Time zone database updates. Governments change rules. Keep your library’s data current.
Test these cases with fixed fixtures. Time-dependent code should take the current time as a parameter, so tests can pin it.
Finally, avoid a thundering herd. If a million users all have a reminder at 8:00, you will enqueue a million jobs at once. Spread the load with small random offsets, if the product allows it, and let workers drain the queue at a steady rate. Respect quiet hours and user preferences, and check them when the job runs as well as when it is scheduled, since settings may have changed in between.
What should you observe?
A queue that fails silently is worse than no queue. Track a small set of signals:
- Queue depth. How many jobs are ready to run now. A growing number means workers are behind.
- Oldest ready job age. The time since the oldest queued job was due. This is the best single health number, because it measures delay that users feel.
- Throughput and duration. Jobs finished per minute, and how long each kind takes.
- Failure and retry rates, by kind and by error.
- Dead job count. Alert when it grows.
- Stuck jobs. Running jobs with an old lock time.
Add structured logs with the job ID, kind, attempt and idempotency key so you can follow one job through retries. Alert on symptoms (jobs late by more than N minutes) rather than on causes.
A small query gives you a lot:
SELECT kind,
count(*) AS ready,
max(now() - run_at) AS oldest_wait
FROM jobs
WHERE status = 'queued' AND run_at <= now()
GROUP BY kind;
Prune finished jobs on a schedule, or partition the table by time, so the table stays small and the index stays fast.
Key takeaways
- A Postgres queue lets you enqueue a job in the same transaction as the change that caused it, and it is enough for most mobile apps.
- Claim jobs with
FOR UPDATE SKIP LOCKED, and add a lease or reaper for workers that crash. - Design for at-least-once delivery: use idempotency keys at enqueue time and idempotent effects in the job.
- Retry transient failures with exponential backoff and jitter, cap attempts, and keep dead jobs for inspection.
- Store schedules as local time plus an IANA zone name, compute the next UTC time each run, and test DST edges.
- Watch queue depth, oldest ready job age, failures and dead jobs.
How does this relate to Edict?
Edict is a mobile backend in development. Its planned background jobs module is designed to cover scheduled tasks, durable event reactions, retries, and execution history, with local workflow execution and simulated notifications so scheduled work can be tested without a live provider. The planned push notifications module would be the usual consumer of reminder jobs. These capabilities are planned, and the patterns above work in any PostgreSQL-backed service today.
The productivity and task management and appointment booking examples both depend on reminders, as does the scheduled prompting in journaling and wellness. For the delivery side, read push notification architecture for APNs and FCM.