Background Jobs

Run asynchronous tasks with separate workers since Thunder is serverless-first.

Background jobs are not supported inside a Thunder app itself, because Thunder is designed for serverless-first deployment. Serverless environments don't keep persistent connections between requests or run long-lived processes.

For asynchronous work, run it outside the request lifecycle using one of the patterns below.

Why No Built-in Background Jobs?

Thunder is serverless-first, which means:

  • No persistent connections between requests
  • No long-running background processes
  • Functions scale independently
  • Each request is stateless and isolated

For async tasks, use external services instead of built-in job queues.


Create a separate worker application that runs on stateful hosting (a VPS, Docker container, Railway, Kubernetes, etc.) and polls your database:

workers/emailWorker.ts
import { mongodb } from "@/database.ts";
import { emailQueueModel } from "@/schemas/emailQueue.ts";

async function processEmailQueue() {
  while (true) {
    const email = await emailQueueModel.findOne({ status: "pending" });

    if (!email) {
      await new Promise((resolve) => setTimeout(resolve, 5000)); // wait 5s
      continue;
    }

    try {
      await sendEmail(email);
      await emailQueueModel.updateOne(
        { _id: email._id },
        { $set: { status: "sent", sentAt: new Date() } }
      );
    } catch (error) {
      await emailQueueModel.updateOne(
        { _id: email._id },
        { $set: { status: "failed", error: error.message } }
      );
    }
  }
}

await processEmailQueue();

Option 2: Third-Party Queue Service

Use a managed job queue such as BullMQ. Enqueue jobs from your Thunder app, and process them in a separate worker:

routes/emails.ts
import Queue from "bullmq";

const emailQueue = new Queue("emails", {
  connection: {
    host: process.env.REDIS_HOST,
    port: process.env.REDIS_PORT,
  },
});

export default new Router("/api", function email(router) {
  router.post("/send", function sendEmail() {
    const $body = z.object({
      to: z.string().email(),
      subject: z.string(),
      body: z.string(),
    });

    return {
      shape: () => ({ body: $body }),
      handler: async (req) => {
        const data = $body.parse(await bodyAsJson(req));

        // Returns immediately; the worker handles delivery.
        await emailQueue.add("send", data, {
          attempts: 3,
          backoff: { type: "exponential", delay: 2000 },
        });

        return Response.ok();
      },
    };
  });
});
workers/emailWorker.ts
import Queue from "bullmq";

const emailQueue = new Queue("emails", {
  connection: { host: process.env.REDIS_HOST },
});

emailQueue.process(async (job) => {
  await sendEmail(job.data);
});

Option 3: Queue in the Database

The simplest approach uses MongoDB itself as a queue. Enqueue from a route handler, then process with a separate worker (like Option 1):

// In a route handler - enqueue
await emailQueueModel.insertOne(
  $emailQueue.strictParse({
    type: "send-email",
    data: { to, subject, body },
    status: "pending",
    createdAt: new Date(),
  })
);

On this page