Skip to content
Get started
Get started
Bunqueue + Hono: Background Jobs in a Bun Web App
View Markdown
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, configured with dataPath so jobs are stored 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 storage = { embedded: true, dataPath: './data/bunq.db' } as const;
const emails = new Queue('emails', storage);
new Worker(
'emails',
async (job) => {
console.log('sending to', job.data.to);
// await sendEmail(job.data);
return { sent: true };
},
{ ...storage, 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, dataPath: './data/bunq.db' }
);

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,
dataPath: './data/bunq.db',
});
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, dataPath: './data/bunq.db' }),
reports: new Queue('reports', { embedded: true, dataPath: './data/bunq.db' }),
};
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 workers to scale and restart independently. Start one bunqueue server as the sole owner of the SQLite file, then connect both processes over TCP:

Terminal window
bunqueue start --data-path ./data/bunq.db

Use the same connection in the Hono app’s Queue instances and the worker:

// In the Hono app
import { Queue } from 'bunqueue/client';
const connection = { host: '127.0.0.1', port: 6789 };
const queues = {
emails: new Queue('emails', { connection }),
reports: new Queue('reports', { connection }),
};
// worker-process.ts (run with: bun run worker-process.ts)
import { Worker } from 'bunqueue/client';
const connection = { host: '127.0.0.1', port: 6789 };
const worker = new Worker(
'emails',
async (job) => {
// ... process job
return { success: true };
},
{ connection, concurrency: 5 }
);
process.on('SIGTERM', async () => {
await worker.close(); // waits for active jobs to finish
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.