Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Queues

Run slow work after the response. One package describes the job, and one small package per platform sends and runs it.

A queue is a waiting line for work. Your app puts a job on the line and answers the user at once. Something else takes the job off the line and runs it later. A job is one piece of work, like sending an email.

Queues are split into three packages:

  • @zap-ts/queues says what a job looks like. It has no code, only types.
  • @zap-ts/queues-cloudflare sends jobs and runs them on Cloudflare Queues.
  • @zap-ts/queues-vercel sends jobs and runs them on Vercel Queues.

Your app depends on one platform package. The shape of the job stays the same on both.

The job

@zap-ts/queues has one file. It holds the Job type. Today there is one kind of job:

export type MailJob = {
  type: "send-email";
  to: string;
  subject: string;
};

Job is that type. Later it will be a list of several job types. The type field says which kind of job it is, so the code that runs the job knows what to do.

Add your own job here. Both platform packages then see it, because both import Job from this one place.

Sending a job

Both platform packages export the same two calls from their /producer path. A producer is the code that puts a job on the queue.

  • sendJob puts one job on the queue.
  • sendJobs puts several jobs on the queue.

On Cloudflare:

import { sendJob } from "@zap-ts/queues-cloudflare/producer";

const effect = sendJob(
  { type: "send-email", to: "user@example.com", subject: "Welcome!" },
  { delaySeconds: 60 },
);

On Vercel:

import { sendJob } from "@zap-ts/queues-vercel/producer";

const effect = sendJob({ type: "send-email", to: "user@example.com", subject: "Welcome!" });

Both calls need a JobQueue layer, which the main export of each package builds. On Cloudflare you pass the queue binding that the Worker gives you. A binding is the handle to a queue that Cloudflare hands your code.

import { JobQueueLive } from "@zap-ts/queues-cloudflare";

On Vercel you pass the name of the queue instead:

import { JobQueueLive } from "@zap-ts/queues-vercel";

const layer = JobQueueLive("zap-ts-jobs");

If a send fails, you get a JobQueueError. It carries the original error in its cause field.

Sending many jobs at once

The two platforms do not behave the same here.

On Cloudflare, sendJobs makes one call. Every job travels together. Each job is wrapped in { body }.

On Vercel there is no call for a group of jobs. So sendJobs sends them side by side, one request each. Every job is sent, but they do not arrive as one unit. If one send fails, that failure is reported, and the other jobs stay on the queue.

So write every job so it can run on its own. Do not write a job that only makes sense next to another job.

Running a job

Both platform packages export a /consumer path. A consumer is the code that takes jobs off the queue and runs them.

On Cloudflare:

import { handleJobBatch } from "@zap-ts/queues-cloudflare/consumer";
import { Effect } from "effect";

export default {
  queue: (batch) =>
    Effect.runPromise(handleJobBatch(batch, (job) => Effect.log(`sending to ${job.to}`))),
};

Cloudflare gives your Worker a batch. A batch is a small group of jobs. handleJobBatch runs your function on each job in the batch. A job that works is marked as done. A job that fails is written to the log and put back on the queue, so the queue tries it again.

On Vercel, jobs do not arrive in a batch. Vercel calls a route of your own app, once per job. So the consumer is a route, and it is built by handleJob:

import { createFileRoute } from "@tanstack/react-router";
import { handleJob } from "@zap-ts/queues-vercel/consumer";
import { Effect } from "effect";

export const Route = createFileRoute("/api/jobs")({
  server: {
    handlers: {
      POST: handleJob((job) => Effect.log(`sending to ${job.to}`)),
    },
  },
});

handleJob gives you the same two outcomes as handleJobBatch. A job that works answers Vercel, and the job is done. A job that fails is written to the log and the route fails, so Vercel Queues delivers the job again.

How many times it tries, and where a job goes after that, is set on the Vercel queue itself, not in this package.

What ships in the web app

apps/web runs on Cloudflare. Its src/server.ts file has a queue handler. The handler passes the batch to handleJobBatch and writes one line to the log for each job. That is a placeholder. Replace the body with the real work.

The queue is set up in apps/web/wrangler.jsonc. It is named zap-ts-jobs. The binding is QUEUE. A batch holds up to 10 jobs, or waits 5 seconds for them. A job is tried up to 3 times. After that it goes to zap-ts-jobs-dlq. A dead letter queue holds the jobs that never worked, so you can look at them later.

Last updated on September 22, 2026

Was this page helpful?