Skip to content
Get started
Get started
bunqueue Use Cases: Background Job Patterns for Bun
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 never run twice. Two options handle this:

  • durable: true skips the write buffer (the small in-memory batch bunqueue normally flushes to disk every 10ms) and writes the job to SQLite before add() returns. A crash cannot lose it.
  • jobId makes the add idempotent: adding the same jobId twice returns the existing job instead of creating a duplicate.
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' },
{ jobId: 'charge-ORD-123', durable: true } // no duplicate, no loss on crash
);

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 are stored in SQLite and survive restarts, in both embedded and server mode. 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
durableWrite to disk immediately instead of the 10ms bufferfalse
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.
  • The default write buffer trades 10ms for speed. Jobs are flushed to SQLite every 10ms. A hard crash can lose jobs accepted in that window. Use durable: true where that matters.
  • 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.