Skip to main content

Documentation Index

Fetch the complete documentation index at: https://upstash.com/docs/llms.txt

Use this file to discover all available pages before exploring further.

What is a queue?

A queue holds items in order: the first item added is the first one taken out (FIFO, or First In First Out), like a line of people waiting for service. In software, a queue lets one part of your system hand work to another so the producer doesn’t have to wait for the work to finish. Redis works well for queues. Its list and sorted set types map onto queue operations, and operations are atomic, so multiple producers and consumers can share a queue without stepping on each other. With Upstash Redis you reach it over HTTP, so the same queue works from a serverless function or a long-running worker. This tutorial starts with a plain FIFO queue and builds up to a job queue that supports delays, priorities, and retries.

Database Setup

Create a Redis database using the Upstash Console. If this is your first one, the Getting Started guide walks through creating a database and finding its credentials. Then copy the REST URL and token into your .env file:
UPSTASH_REDIS_REST_URL=your_upstash_redis_url
UPSTASH_REDIS_REST_TOKEN=your_upstash_redis_token

Installation

npm install @upstash/redis
Then create a client. Redis.fromEnv() reads the two variables above automatically:
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

A basic FIFO queue

A Redis list is all you need for a simple queue. You push items onto one end and pop them off the other. The convention is to enqueue on the left with LPUSH and dequeue on the right with RPOP, so the oldest item is always the next one out.
// Producer — add tasks to the queue
await redis.lpush("tasks", "send-welcome-email:alice");
await redis.lpush("tasks", "send-welcome-email:bob");

// Consumer — take the next task (returns "send-welcome-email:alice")
const task = await redis.rpop("tasks");
OperationCommandDescription
EnqueueLPUSH queue taskAdd a task to the head of the list
DequeueRPOP queueRemove and return the oldest task
PeekLRANGE queue -1 -1Look at the next task without removing it
LengthLLEN queueHow many tasks are waiting
To peek at the next task without removing it, read the tail with LRANGE. This is useful for monitoring or for deciding whether to process:
const [next] = await redis.lrange("tasks", -1, -1);
This is enough for fire-and-forget work, but it has a flaw: if your consumer pops a task and then crashes before finishing it, that task is lost. The next sections fix that.

A reliable queue

To avoid losing tasks, don’t fully remove a task until it has been processed. Redis’s LMOVE command (the modern replacement for RPOPLPUSH) atomically moves a task from the main queue to a “processing” list in a single step:
// Atomically move the next task from "tasks" to "processing"
const task = await redis.lmove("tasks", "processing", "right", "left");

if (task) {
  try {
    await handle(task);
    // Success: remove it from the processing list
    await redis.lrem("processing", 1, task);
  } catch (err) {
    // Failure: the task is still safe in "processing".
    // A recovery job can move stuck tasks back to "tasks" later.
    console.error("task failed, left in processing list", err);
  }
}
Because the task stays in processing while it is being worked on, a crashed consumer doesn’t cause data loss. A periodic recovery job can scan processing for tasks that have been stuck too long and move them back to tasks.

Consuming the queue

A consumer reads from the queue in a loop. With a TCP Redis client you might use a blocking pop (BRPOP) to sleep until a task arrives, but @upstash/redis talks to Redis over HTTP, so it doesn’t expose blocking commands. Instead, poll with RPOP and back off with a short sleep when the queue is empty:
async function consume() {
  while (true) {
    const task = await redis.rpop<string>("tasks");
    if (task === null) {
      await new Promise((r) => setTimeout(r, 1000)); // queue empty, wait before retrying
      continue;
    }
    await handle(task);
  }
}
Polling works well for a long-running worker, but in short-lived serverless functions you usually don’t want to run a loop at all. For event-driven delivery, consider Upstash QStash, which pushes messages to your HTTP endpoint instead of making you poll. We come back to this at the end.

Going further: a delayed, prioritized job queue with retries

Plain lists are FIFO and immediate. Real job systems usually need a few more things:
  • Delays: run a job later, like a reminder in an hour, instead of right away.
  • Priorities: let urgent jobs go ahead of routine ones.
  • Retries: when a job keeps failing, retry it a few times and then set it aside rather than retrying forever.
A sorted set handles all three. It keeps members ordered by a numeric score. If the score is the time a job becomes due, the member with the lowest score is always the next job to run, and jobs scheduled for the future sit untouched until their time comes.

The schedule: score = “run at” timestamp

type Job = { id: string; type: string; payload: unknown };
type ScheduledJob = Job & { attempts?: number };

// Enqueue a job to run after `delayMs` (0 = immediately).
// `priority` shaves milliseconds off the score so higher-priority jobs
// of the same due-time sort first.
async function enqueue(job: ScheduledJob, delayMs = 0, priority = 0) {
  const runAt = Date.now() + delayMs - priority;
  await redis.zadd("jobs:scheduled", { score: runAt, member: JSON.stringify(job) });
}

// Routine job, runs now
await enqueue({ id: "1", type: "email", payload: { to: "alice@example.com" } });

// Reminder, runs in one hour
await enqueue({ id: "2", type: "reminder", payload: { userId: 42 } }, 60 * 60 * 1000);

// Urgent job — higher priority means it sorts ahead of same-time jobs
await enqueue({ id: "3", type: "alert", payload: { level: "critical" } }, 0, 10_000);

Claiming due jobs atomically

A worker should only pick up jobs whose runAt is in the past, and when several workers run at once, no two should claim the same job. A Lua script handles this: it reads the due jobs and removes them in one step, so there is no race between the read and the remove.
// Returns up to `limit` jobs that are due, and removes them from the schedule
// in the same atomic operation. `unpack` is available in Redis's Lua (5.1); on
// newer Lua you'd use `table.unpack`. Keep `limit` modest so the unpacked
// argument list stays well within Lua's stack limits.
const CLAIM = `
  local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
  if #due > 0 then
    redis.call('ZREM', KEYS[1], unpack(due))
  end
  return due
`;

async function claimDueJobs(limit = 10): Promise<ScheduledJob[]> {
  // The members were stored as JSON, and @upstash/redis deserializes JSON in
  // command results by default, so `eval` hands back parsed objects, not strings.
  return (await redis.eval(
    CLAIM,
    ["jobs:scheduled"],
    [Date.now().toString(), limit.toString()],
  )) as ScheduledJob[];
}

The worker: process, retry, or dead-letter

When a job fails, we re-schedule it with an exponential back-off delay and increment its attempt count. Once it runs out of retries, it goes to a dead-letter queue: a separate list of jobs to look at later, rather than retrying them again.
const MAX_ATTEMPTS = 3;

async function processJob(job: ScheduledJob) {
  const attempts = job.attempts ?? 0;

  try {
    await handle(job); // your business logic
  } catch (err) {
    if (attempts + 1 >= MAX_ATTEMPTS) {
      // Out of retries: set it aside for inspection. @upstash/redis serializes
      // the object to JSON for us, so we pass it directly.
      await redis.lpush("jobs:dead-letter", {
        job,
        error: String(err),
        failedAt: Date.now(),
      });
    } else {
      // Re-schedule with exponential back-off: 1s, 2s, 4s, ...
      const backoffMs = 1000 * 2 ** attempts;
      await enqueue({ ...job, attempts: attempts + 1 }, backoffMs);
    }
  }
}

// Worker loop
async function runWorker() {
  while (true) {
    const jobs = await claimDueJobs();
    if (jobs.length === 0) {
      await new Promise((r) => setTimeout(r, 1000)); // nothing due, wait before retrying
      continue;
    }
    await Promise.all(jobs.map(processJob));
  }
}

runWorker();
This gives you a job queue that schedules work for later, runs urgent jobs first, retries failures with back-off, and moves jobs that keep failing to a dead-letter queue, all on a single sorted set.

Inspecting the dead-letter queue

Failed jobs live in a list, so checking on them is straightforward. You can wire this into a dashboard or an alert:
type DeadLetter = { job: ScheduledJob; error: string; failedAt: number };

const failedCount = await redis.llen("jobs:dead-letter");
// lrange also deserializes JSON, so these come back as objects, not strings.
const failed = await redis.lrange<DeadLetter>("jobs:dead-letter", 0, 9); // 10 most recent

Wrapping up

Redis covers queues at several levels, all in the same database:
  • Lists (LPUSH / RPOP) for simple FIFO queues, plus LMOVE for reliability and BRPOP for blocking consumers.
  • Sorted sets (ZADD / ZRANGEBYSCORE) for delayed and prioritized scheduling.
  • Lua scripts (EVAL) to claim work atomically across many concurrent workers.
Because Upstash Redis is serverless and accessed over HTTP, you can produce jobs from serverless or edge functions and consume them from wherever your workers run.

When to reach for QStash instead

The job queue above assumes you have a worker process polling Redis. In a fully serverless setup you often don’t want to keep a worker running. That is where Upstash QStash comes in: instead of pulling jobs from a list, you publish a message and QStash pushes it to your HTTP endpoint, with retries, scheduling, and delays handled for you.
import { Client } from "@upstash/qstash";

const qstash = new Client({ token: process.env.QSTASH_TOKEN! });

// Deliver this job to your endpoint after a one-hour delay
await qstash.publishJSON({
  url: "https://your-app.com/api/jobs/reminder",
  body: { userId: 42 },
  delay: 60 * 60, // seconds
});
A rough rule of thumb: use the Redis patterns in this guide when you control the consumer and want full control over how jobs are stored and claimed. Reach for QStash when you’d rather not run a worker at all and just want jobs delivered to an endpoint. See the QStash documentation to get started.