Skip to content
Get started
Get started
Bunqueue + Hono: Background Jobs in a Bun Web App
guide · hono

Background jobs for Hono.

Return the HTTP response now, do the slow work later. This guide wires bunqueue into Hono: enqueue from routes, process in a worker, check job status, shut down cleanly.

This guide shows how to run background jobs, work your server does after the HTTP response is sent, inside a Hono app. Everything runs in one process using bunqueue’s embedded mode, which stores jobs in a local SQLite file instead of a separate queue server.

Copy, run with bun run app.ts, done:

import { Hono } from 'hono';
import { Queue, Worker, shutdownManager } from 'bunqueue/client';
// Queue: where jobs wait. Worker: runs your function on each job.
const emails = new Queue('emails', { embedded: true });
new Worker('emails', async (job) => {
console.log('sending to', job.data.to);
// await sendEmail(job.data);
return { sent: true };
}, { embedded: true, concurrency: 3 }); // 3 jobs in parallel
const app = new Hono();
app.post('/api/send-email', async (c) => {
const body = await c.req.json();
const job = await emails.add('send', body, {
attempts: 3, // retry up to 3 times on failure
backoff: 5000, // wait 5s (then longer) between retries
});
return c.json({ queued: true, jobId: job.id });
});
process.on('SIGINT', () => {
shutdownManager();
process.exit(0);
});
export default app;

The route responds immediately. The worker sends the email in the background and retries automatically if it throws.

Return the job id from the enqueue route, then expose a status endpoint. job.progress is a 0-100 number your worker sets, returnvalue is what your worker returned, failedReason is the last error message:

app.get('/api/jobs/:id', async (c) => {
const job = await emails.getJob(c.req.param('id'));
if (!job) return c.json({ error: 'Job not found' }, 404);
return c.json({
id: job.id,
name: job.name,
progress: job.progress,
result: job.returnvalue ?? null,
error: job.failedReason ?? null,
});
});
new Worker('reports', async (job) => {
await job.updateProgress(10, 'Fetching data');
const data = await fetchData(job.data);
await job.updateProgress(80, 'Rendering PDF');
const url = await renderPdf(data);
return { url };
}, { embedded: true });

getJobCounts() is synchronous in embedded mode and returns counts per state (waiting, active, completed, failed, delayed):

app.get('/api/queues/emails/stats', (c) => c.json(emails.getJobCounts()));
const worker = new Worker('emails', processor, { embedded: true });
worker.on('completed', (job, result) => console.log('done', job.id));
worker.on('failed', (job, err) => console.error('failed', job.id, err.message));

For larger apps, put queues on Hono’s context so every route gets them typed:

import { Hono } from 'hono';
import type { MiddlewareHandler } from 'hono';
import { Queue } from 'bunqueue/client';
const queues = {
emails: new Queue('emails', { embedded: true }),
reports: new Queue('reports', { embedded: true }),
};
type Env = { Variables: { queues: typeof queues } };
const queueMiddleware: MiddlewareHandler<Env> = async (c, next) => {
c.set('queues', queues);
await next();
};
const app = new Hono<Env>();
app.use('*', queueMiddleware);
app.post('/api/reports', async (c) => {
const job = await c.get('queues').reports.add('generate', await c.req.json());
return c.json({ jobId: job.id });
});

In production you often want the web server and the workers to scale and restart independently. Both processes point at the same SQLite file via dataPath:

// worker-process.ts (run with: bun run worker-process.ts)
import { Worker, shutdownManager } from 'bunqueue/client';
const worker = new Worker('emails', async (job) => {
// ... process job
return { success: true };
}, { embedded: true, concurrency: 5 });
process.on('SIGTERM', async () => {
await worker.close(); // waits for active jobs to finish
shutdownManager();
process.exit(0);
});

Close workers first (each close() waits for its active jobs), then release the embedded manager:

import { shutdownManager } from 'bunqueue/client';
async function shutdown() {
await Promise.all(workers.map((w) => w.close()));
shutdownManager();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
  • One Queue instance per queue name, created at startup. Creating queues inside handlers works but wastes memory and setup time on every request.
  • Long jobs need a timeout. The default processing timeout comes from the job options; set timeout: 300_000 for a 5 minute report job so it is not killed early.
  • CPU-heavy processors block the event loop, the single thread Bun uses for all I/O. See CPU-Intensive Workers for yield patterns.