Every SaaS has background jobs. Send welcome emails 5 minutes after signup. Recalculate dashboards every hour. Export a CSV file and email it. Process a webhook from Stripe. The JavaScript world defaults to BullMQ + Redis: spin up a Redis instance, queue jobs, spin up 2–4 worker processes, done. It works. But Redis is infrastructure — it costs money, takes up deployment slots, and needs monitoring. For most SaaS products, Postgres can be your queue. Velocity X uses this pattern: stick jobs in a `queue` table, run a pg_cron task every minute, pick up pending jobs, process them in an Edge Function, mark them done. No Redis. No workers. Just a table and a scheduled function. If you actually hit the ceiling (sub-second latency, 1000+ jobs/sec), then escalate to BullMQ. But 95% of products won't.
What Is a Job Queue?
A job queue stores work that needs to happen asynchronously — outside the request cycle. Instead of sending an email inside a POST handler (which blocks the response), you insert a row into a `queue` table, return 200 immediately, and process the email later. Queues decouple producers (your app) from consumers (worker processes). A producer pushes jobs in. A worker pops jobs out, processes them, and deletes them. If a job fails, the queue retries it. If workers go down, jobs wait. Once a worker comes back online, it picks up where it left off.
Why Postgres Beats Redis (Most of the Time)
Postgres is already running. It has transactions, ACID guarantees, and you can inspect the queue with SQL. BullMQ is faster at sub-second scales, but Velocity SaaS doesn't operate at that speed. Your slowest operations are external (API calls, file uploads, emails) — they're not bound by queue throughput. Redis also requires another service to deploy, another connection string in your `.env`, another thing to monitor in production. Postgres does none of that. You write one table, one function, and you're done. The tradeoff: jobs process every minute instead of instantly. That's fine for 99% of SaaS workflows.
The Postgres Queue Schema
Create a simple jobs table with status tracking:
-- Core jobs table
create table if not exists jobs (
id uuid primary key default gen_random_uuid(),
type text not null, -- 'send_welcome_email', 'export_csv', etc
payload jsonb not null,
status text default 'pending', -- pending, processing, completed, failed
attempt_count int default 0,
max_attempts int default 3,
error_message text,
processing_started_at timestamp,
completed_at timestamp,
created_at timestamp default now(),
updated_at timestamp default now()
);
-- Indexes for fast queries
create index idx_jobs_status on jobs(status);
create index idx_jobs_type on jobs(type);
create index idx_jobs_created_at on jobs(created_at desc);
-- Enqueue a job (inside your app logic)
insert into jobs (type, payload)
values ('send_welcome_email', '{"user_id": "abc123", "email": "user@example.com"}');
That's it. The `status` field drives the workflow. A worker polls for `status = 'pending'`, marks it `processing`, does the work, then sets it to `completed` or `failed`.
The Worker Pattern — pg_cron + Edge Function
Schedule a pg_cron task to fire every minute. It calls an Edge Function that processes up to 10 pending jobs:
-- Schedule worker to run every minute
select cron.schedule(
'job-worker',
'* * * * *',
'select net.http_post(
''https://[your-project].functions.supabase.co/job-worker'',
jsonb_build_object(''batch_size'', 10)
)'
);
-- The Edge Function: supabase/functions/job-worker/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.104.0';
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
Deno.serve(async (req) => {
const { batch_size } = await req.json();
// Fetch pending jobs
const { data: jobs, error } = await supabase
.from('jobs')
.select('*')
.eq('status', 'pending')
.order('created_at')
.limit(batch_size);
if (error || !jobs?.length) {
return new Response(JSON.stringify({ processed: 0 }), { status: 200 });
}
let processed = 0;
for (const job of jobs) {
try {
// Mark as processing
await supabase
.from('jobs')
.update({ status: 'processing', processing_started_at: new Date() })
.eq('id', job.id);
// Route to handler
const result = await handleJob(job, supabase);
// Mark complete
await supabase
.from('jobs')
.update({ status: 'completed', completed_at: new Date() })
.eq('id', job.id);
processed++;
} catch (err) {
// Retry or fail
const nextAttempt = (job.attempt_count || 0) + 1;
if (nextAttempt >= job.max_attempts) {
await supabase
.from('jobs')
.update({
status: 'failed',
error_message: err.message,
attempt_count: nextAttempt
})
.eq('id', job.id);
} else {
await supabase
.from('jobs')
.update({ attempt_count: nextAttempt })
.eq('id', job.id);
}
}
}
return new Response(
JSON.stringify({ processed, total: jobs.length }),
{ status: 200 }
);
});
// Route jobs by type
async function handleJob(job, supabase) {
switch (job.type) {
case 'send_welcome_email':
return await sendWelcomeEmail(job.payload, supabase);
case 'export_csv':
return await exportCsv(job.payload, supabase);
case 'process_webhook':
return await processWebhook(job.payload, supabase);
default:
throw new Error(`Unknown job type: ${job.type}`);
}
}
async function sendWelcomeEmail(payload, supabase) {
// Fetch user, send email via Resend, etc
return { ok: true };
}
async function exportCsv(payload, supabase) {
// Generate CSV, upload to storage, email link
return { ok: true };
}
async function processWebhook(payload, supabase) {
// Validate Stripe webhook, update database
return { ok: true };
}
The pattern: every minute, pg_cron wakes up, calls the worker function, which picks up 10 jobs (or however many fit in your time budget), processes them, and updates the queue table. If a job errors, it increments `attempt_count` and retries next cycle. After 3 failed attempts, it's marked `failed` and someone (you) investigates.
Observability — Query Your Queue
No queue dashboard needed. SQL is your dashboard:
-- How many jobs pending?
select count(*) from jobs where status = 'pending';
-- Jobs that failed
select id, type, error_message, created_at from jobs
where status = 'failed'
order by created_at desc;
-- Average time to process (all completed jobs)
select avg(completed_at - processing_started_at) as avg_duration
from jobs
where status = 'completed'
and completed_at is not null;
-- Backlog — how far behind are we?
select count(*) as pending, min(created_at) as oldest
from jobs
where status = 'pending';
If the `oldest` job is more than 5 minutes old, you have backlog. Increase batch size, split the worker into multiple functions by job type, or add a second worker instance.
When to Escalate to BullMQ
Postgres queues work until they don't. Escalate to BullMQ + Redis when:
1. Sub-second latency required. Your users need jobs processed instantly, not batched every 60 seconds. Real-time notifications, API responses that depend on job completion, etc. Redis is 100x faster.
2. 100+ jobs/sec sustained. Postgres polling every minute can't keep up. You'd need a worker per 10K jobs/sec with Postgres; Redis handles that in one process.
3. Job dependencies or workflows. You need job A to complete, then trigger job B, then trigger job C. Postgres is row-based; complex workflows are easier in BullMQ's job graph patterns.
4. Priority queues. Some jobs are urgent (refund a user) vs batch (nightly aggregation). Redis supports priority queues natively. Postgres needs a `priority` column and sorting, which is doable but feels hacky.
For Velocity SaaS (dashboards, analytics, internal tools), none of these apply. Stick with Postgres.
Six FAQs
What if the worker crashes mid-job?
Jobs in `processing` status stay there. On the next worker cycle, you can check `processing_started_at` — if it's older than 10 minutes, assume the job is stuck and reset it to `pending`. Add a cleanup query to your worker function.
Can I manually trigger a job without queueing?
Yes. Call the handler function directly from your app if you need synchronous behavior. Queue it if you want async. Both patterns coexist.
How do I scale to multiple workers?
One pg_cron worker processes 10 jobs/min. If you have 100+ jobs/min, scale horizontally: change the batch size to 30, or add a second pg_cron schedule that runs the same worker every minute but processes different job types. Postgres locking prevents two workers from processing the same job.
Can I monitor jobs from my app?
Yes. Query the jobs table from your RLS-protected API endpoint. Show users the status of their export, email delivery, webhook processing, etc. Supabase RLS enforces tenancy.
What if I need to pause all jobs?
Delete the pg_cron schedule. Jobs stay in the table. Re-enable the schedule when ready. It's that simple.
How much does this cost?
One pg_cron schedule is free. The Edge Function costs a few cents per month (one invocation per minute). Negligible.
The Verdict
BullMQ is battle-tested and faster. But for SaaS products that don't operate at high throughput, a Postgres jobs table is simpler, cheaper, and requires less infrastructure. Velocity X uses this pattern across 50+ dashboards. Jobs process every 60 seconds, which is fast enough for welcome emails, CSV exports, and webhook handling. If you hit the ceiling, port to BullMQ — but don't pre-optimize for speed you don't need. Start with Postgres. For more on database-driven SaaS patterns, check Velocity X partnerships. And if you want production-grade scheduled jobs (not background queues), see Supabase Edge Functions + pg_cron.