Skip to content
Get started
Get started
bunqueue Use Cases: Background Job Patterns for Bun
View Markdown
guide · use-cases

The use cases teams run.

Six patterns you can copy into a real app: emails, webhooks, images, payments, cron and multi-step flows. Each one moves slow work out of your API request and into a background job.

This page shows the most common things people build with bunqueue, each with a small working example. If a term is new to you, it gets a plain-words explanation the first time it appears.

Every use case below is a variation of the same idea: your API handler adds a job (a unit of work saved as data) to a queue and returns immediately. A worker (a function that pulls jobs and runs them) does the slow part in the background.

import { Queue, Worker } from 'bunqueue/client';
// Embedded mode: the queue runs inside your process, no separate server.
// dataPath persists jobs to a SQLite file so they survive restarts.
const queue = new Queue('emails', { embedded: true, dataPath: './data/app.db' });
new Worker('emails', async (job) => {
await sendEmail(job.data);
return { sent: true };
}, { embedded: true, concurrency: 10 }); // up to 10 jobs in parallel
// In your API handler: this returns in microseconds
await queue.add('welcome', { to: 'user@example.com' });

That is the whole model. The sections below add the options that make each use case reliable. New to bunqueue? Start with the quickstart.

Sending email inside an API request is slow and fragile: the provider can be down or rate limited. Queue it instead, and let bunqueue retry on failure.

import { Queue, Worker } from 'bunqueue/client';
interface EmailJob { to: string; template: string; data: Record<string, unknown> }
const emails = new Queue<EmailJob>('emails', {
embedded: true,
dataPath: './data/app.db',
defaultJobOptions: {
attempts: 5, // try up to 5 times
backoff: 2000, // wait 2s, then 4s, 8s... between tries (exponential backoff)
removeOnComplete: true, // drop finished jobs to keep the queue lean
},
});
new Worker<EmailJob>('emails', async (job) => {
const result = await sendEmail(job.data);
return { messageId: result.messageId };
}, { embedded: true, concurrency: 10 });
// One email
await emails.add('welcome', { to: 'user@example.com', template: 'welcome', data: { name: 'John' } });
// Bulk newsletter, batched in one call
await emails.addBulk(
subscribers.map((s) => ({ name: 'newsletter', data: { to: s.email, template: 'news', data: {} } }))
);

PHP, Rust and Elixir follow the same shape, add / addBulk on the producer and a worker with retries handled server-side, see the SDK guide.

If all 5 attempts fail, the job lands in the dead letter queue (DLQ), a holding area for jobs that ran out of retries. Inspect it with queue.getDlq() and retry with queue.retryDlq(). See the DLQ guide.

Partner endpoints go down and return 5xx errors. Treat every delivery as a job with retries, and let the DLQ auto-retry the stubborn ones on a schedule.

const webhooks = new Queue('webhooks', {
embedded: true,
dataPath: './data/app.db',
defaultJobOptions: { attempts: 8, backoff: 5000 }, // 5s, 10s, 20s, 40s...
});
// Jobs that exhaust all 8 attempts go to the DLQ.
// Auto-retry the DLQ every hour, up to 3 times, then keep entries 7 days.
webhooks.setDlqConfig({
autoRetry: true,
autoRetryInterval: 3_600_000,
maxAutoRetries: 3,
maxAge: 604_800_000,
});
new Worker('webhooks', async (job) => {
const { endpoint, event, payload } = job.data;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webhook-Event': event },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000), // never hang on a dead endpoint
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // throwing triggers a retry
return { status: res.status };
}, { embedded: true, concurrency: 20 });
await webhooks.add('order.created', {
endpoint: 'https://partner.com/webhooks',
event: 'order.created',
payload: { orderId: 'ORD-123' },
});

PHP, Rust and Elixir deliver webhooks with the same worker shape (see the SDK guide). DLQ auto-retry configuration (setDlqConfig) is available from the Bun, TypeScript and Python clients.

Generating thumbnails and variants during an upload request makes the upload slow. Queue one job per image, report progress as each variant finishes, and cap the runtime with a timeout.

const images = new Queue('images', {
embedded: true,
dataPath: './data/app.db',
defaultJobOptions: { attempts: 3, timeout: 120_000 }, // kill stuck jobs after 2 minutes
});
new Worker('images', async (job) => {
const { sourceUrl, variants } = job.data;
const source = await downloadImage(sourceUrl);
const urls: Record<string, string> = {};
for (let i = 0; i < variants.length; i++) {
const v = variants[i];
// updateProgress(percent, message): your frontend can poll or subscribe to this
await job.updateProgress(Math.round((i / variants.length) * 100), `Processing ${v.name}`);
const out = await sharp(source).resize(v.width, v.height).webp().toBuffer();
urls[v.name] = await uploadToCDN(out, `${job.id}/${v.name}.webp`);
}
await job.updateProgress(100, 'Done');
return { urls };
}, { embedded: true, concurrency: 5 });
await images.add('product-image', {
sourceUrl: 'https://uploads.example.com/raw/product-123.jpg',
variants: [
{ name: 'thumb', width: 150, height: 150 },
{ name: 'full', width: 1200, height: 900 },
],
});

PHP, Rust and Elixir report progress the same way (updateProgress / update_progress), see the Worker guide.

The same shape works for video transcoding or any long CPU-bound task, just raise the timeout. For heavy CPU work see CPU-intensive workers.

A payment job must never be lost, and its external side effect must be idempotent because bunqueue provides at-least-once delivery. Two job options support that design:

  • On SQLite, durable: true skips the 10ms write buffer and writes the job before add() returns. PostgreSQL admissions are already transactional and do not use that buffer.
  • jobId makes enqueueing idempotent: adding the same unfinished jobId twice returns the existing job instead of creating another generation. Use the same key with the payment provider because a lease recovery can redeliver a job after its side effect completed but before its ACK committed.
const payments = new Queue('payments', {
embedded: true,
dataPath: './data/payments.db', // durable writes need a SQLite file
defaultJobOptions: { attempts: 3, backoff: 5000, timeout: 60_000 },
});
// Failed payments need a human, never an automatic re-charge
payments.setDlqConfig({ autoRetry: false, maxAge: 2_592_000_000 }); // keep 30 days
new Worker('payments', async (job) => {
const { orderId, amount, idempotencyKey } = job.data;
const intent = await stripe.paymentIntents.create(
{ amount, currency: 'usd', confirm: true },
{ idempotencyKey } // provider-side guard against double charges
);
if (intent.status !== 'succeeded') throw new Error(`Payment failed: ${intent.status}`);
await recordTransaction(orderId, intent.id);
return { paymentIntentId: intent.id };
}, { embedded: true, concurrency: 5 });
await payments.add(
'charge',
{ orderId: 'ORD-123', amount: 9999, idempotencyKey: 'order-ORD-123' },
// Custom ID deduplicates while retained; durable closes SQLite's admission buffer window.
{ jobId: 'charge-ORD-123', durable: true }
);

PHP, Rust and Elixir support jobId and durable identically, see the SDK guide. DLQ auto-retry configuration (setDlqConfig) is available from the Bun, TypeScript and Python clients.

Recurring jobs use upsertJobScheduler(). Schedules survive restarts when the selected backend is persistent: SQLite stores them in embedded or single-broker server mode, while PostgreSQL stores and coordinates them across the broker fleet. A cron expression like 0 3 * * * means “every day at 3 AM”.

const scheduled = new Queue('scheduled', { embedded: true, dataPath: './data/app.db' });
// Daily cleanup at 3 AM
await scheduled.upsertJobScheduler(
'daily-cleanup',
{ pattern: '0 3 * * *' },
{ data: { task: 'cleanup' } }
);
// Health check every 5 minutes
await scheduled.upsertJobScheduler(
'health-check',
{ every: 300_000 },
{ data: { task: 'health-check' } }
);
new Worker('scheduled', async (job) => {
if (job.data.task === 'cleanup') return { deleted: await cleanupOldRecords() };
return await checkSystemHealth();
}, { embedded: true });

Timezones, one-off delayed jobs and the repeat shorthand on queue.add() are covered in the cron guide.

Some work has dependencies: an order ships only after inventory and payment both check out. FlowProducer runs child jobs first, in parallel, then runs the parent with access to every child result.

import { FlowProducer, Worker } from 'bunqueue/client';
type OrderData = { orderId: string };
const flow = new FlowProducer({ embedded: true });
const checks = new Worker<OrderData>('checks', async (job) => ({
check: job.name,
approved: true,
}), { embedded: true });
const orders = new Worker<OrderData>('orders', async (job) => {
// Children finished first; read what each one returned
const results = await job.getChildrenValues();
return { orderId: job.data.orderId, shipped: true, checks: results };
}, { embedded: true });
const node = await flow.add<OrderData>({
name: 'fulfill-order',
queueName: 'orders',
data: { orderId: 'ORD-123' },
children: [
{ name: 'check-inventory', queueName: 'checks', data: { orderId: 'ORD-123' } },
{ name: 'check-payment', queueName: 'checks', data: { orderId: 'ORD-123' } },
],
});
const result = await node.job.waitUntilFinished(null, 10_000);
console.log(result);
await checks.close();
await orders.close();
await flow.close();

Rust and Elixir support the same flow trees (FlowProducer::add / Bunqueue.FlowProducer.add) and sequential chains, see the SDK guide.

flow.addChain([...]) runs jobs one after another, and flow.addBulkThen(jobs, finalJob) fans out in parallel and merges at the end (fan-in is available in the TypeScript and Python clients). See the flow guide. For richer orchestration (branching, rollback on failure, human approval steps) use the workflow engine.

The options you saw above, all set per job or via defaultJobOptions:

OptionWhat it doesDefault
attemptsMax tries before the job goes to the DLQ3
backoffBase delay between retries, doubles each time1000 ms
timeoutMax processing time before the job is failednone
priorityHigher numbers run sooner0
delayWait this many ms before the job is runnable0
jobIdCustom ID, adding the same ID twice returns the existing jobauto
durableSQLite: bypass its 10ms buffer; PostgreSQL is already transactionalfalse
removeOnCompleteDelete the job once it succeedsfalse

Full list in the queue guide.

  • No dataPath means no persistence. An embedded queue without dataPath (or a DATA_PATH env var) keeps everything in memory and loses it on restart.
  • SQLite’s default write buffer trades 10ms for speed. A hard crash can lose jobs accepted in that window; use durable: true where that matters. PostgreSQL has no equivalent admission buffer.
  • Throwing is how you retry. A worker that catches every error and returns normally marks the job completed. Let errors propagate when you want a retry.
  • Do not auto-retry money. Set setDlqConfig({ autoRetry: false }) on payment-like queues so failed charges wait for review.
  • Close workers on shutdown. await worker.close() waits for active jobs to finish; worker.close(true) forces a stop. See production for the full shutdown pattern.