Async Jobs

Background job processing with Cloudflare Queues, a dead-letter queue, and hourly Cron.

Architecture

Nuxfire processes background work with two independent Cloudflare primitives:

  1. Queue-based jobs — Cloudflare Queues, behind the jobsQueue flag in config.ts.
  2. Cron — a Cloudflare Cron Trigger, deployed unconditionally today; infra/cron.ts doesn't currently check the cron flag declared in config.ts's flags type.

Queue consumer

apps/functions/src/jobs-consumer.ts processes messages in batches. On success it marks the job completed and writes a job completed log line; on failure it marks the job failed, writes the real error text to a log line, and calls message.retry():

apps/functions/src/jobs-consumer.ts
for (const message of batch.messages) {
  const job = message.body as typeof schema.jobs.$inferSelect;
  try {
    await db.update(schema.jobs).set({ status: "completed" }).where(eq(schema.jobs.id, job.id));
    await db.insert(schema.logs).values({ jobId: job.id, message: "job completed" });
    message.ack();
  } catch (err) {
    await db.update(schema.jobs).set({ status: "failed" }).where(eq(schema.jobs.id, job.id));
    await db.insert(schema.logs).values({
      jobId: job.id,
      message: `job failed: ${err instanceof Error ? err.message : String(err)}`,
    });
    message.retry(); // governed by infra/jobs.ts's retry_delay/max_retries
  }
}

Dead-letter queue

Once Cloudflare Queues exhausts the retries configured in infra/jobs.ts, the message routes to JobDLQ. apps/functions/src/dlq-consumer.ts reads the job's most recent log line (written by the consumer above) as the failure reason, inserts a DeadLetterRecord holding the original Job object verbatim, and acknowledges — it never retries itself. Replaying a dead-lettered message is a deliberate human action, not automatic.

Cron

apps/functions/src/cron.ts runs on the schedule set in infra/cron.ts (0 * * * * — every hour) and writes a Log row; jobId defaults to "cron" for these scheduled runs, distinguishing them from queue-job logs.

Configuration

infra/jobs.ts
export const jobsDeadLetterQueue = flags.jobsQueue
  ? new sst.cloudflare.Queue("JobDLQ")
  : undefined;

export const jobsQueue = flags.jobsQueue
  ? new sst.cloudflare.Queue("JobQueue", {
      maxConcurrency: 5,
      dlq: { queue: jobsDeadLetterQueue!.nodes.queue.queueName, retry: 3, retryDelay: "60 seconds" },
    })
  : undefined;
infra/cron.ts
export const emails = new sst.cloudflare.Cron("Cron", {
  schedules: ["0 * * * *"],
  job: { handler: "apps/functions/src/cron.ts", link: [database] },
});

Admin operations

Queue health, dead-letter messages, and recent cron log lines are surfaced at /admin/queue — see Platform Admin Console. Replaying a dead-lettered message re-sends the original Job object to JobQueue and stamps replayedAt (never cleared, so a message can only be replayed once from the console without a direct database edit).

Database Schema

  • Job — id, message, status (pending / processing / completed / failed), teamId, timestamps.
  • Log — id, message, jobId (defaults to "cron" for scheduled runs), createdAt.
  • DeadLetterRecord — id, message (jsonb — the whole original Job object, so it can be replayed as-is), error, teamId (nullable — a malformed message can still be recorded), createdAt, replayedAt (null = never replayed).

Current scope

Job creation isn't exposed to tenants today. jobsQueue.send() is called only from the admin replay action (admin.queue.replayDeadLetter) — there's no tenant-facing UI or tRPC procedure yet that enqueues a new job. The queue and cron infrastructure are real and process actual Worker-originated messages; a self-service tenant job queue was intentionally scoped out of the current release.

Nuxfire Production Kit

Ready to build and launch your SaaS?

Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.

© 2026 Nuxfire