Skip to main content
Production Next.js · Blog 23

Next.js 16 Background Jobs and Queues with Redis

Move slow and retryable work out of HTTP requests with validated BullMQ jobs, private Redis, independently scaled workers, and production-grade failure handling.

Next.js application sending queued jobs through Redis to independent email, AI, and document workers

Next.js background jobs are units of slow, retryable, or resource-heavy server work that run outside the request that created them. The Next.js application validates a command, stores a small job in a durable queue, responds quickly, and lets an independently deployed worker process email, AI, media, import, or webhook work.

Architecture audit

This publishing repository is PHP/Apache, not the example Next.js service. It contains no installed Next.js package, Redis client, BullMQ package, worker, scheduled job, Compose runtime, or production Redis connection. The downloadable starter uses floating next: latest, so an exact patch version cannot be verified. The code is production-oriented educational TypeScript; it does not silently install or deploy queue infrastructure.

  1. Blog #20Docker + TraefikPublished
  2. Blog #21Compose StackPublished
  3. Blog #22RedisPublished
  4. Blog #23Jobs + QueuesCurrent
  5. Blog #24MonitoringPublished

How Do Background Jobs Work in Next.js?

A Next.js background-job architecture sends slow or retryable work to a queue instead of completing it inside the HTTP request. The web application adds a job to a queue such as BullMQ backed by Redis, returns a response, and a separate worker processes the job asynchronously.

Diagram 1: Request processing versus background processingA synchronous request waits for every operation, while a queued request returns after acceptance and a separate worker completes the slow task.
Inside requestValidate → slow API → email → responseLong latency and fragile timeout
Background pathValidate → enqueue → 202 responseWorker performs slow work later

Why Next.js Apps Need Background Processing

HTTP requests have finite lifetimes. A browser can disconnect, a reverse proxy can time out, a serverless invocation can end, or a deployment can replace the process. Returning a response does not guarantee arbitrary in-process work will continue. A durable queue creates an explicit handoff, retry policy, concurrency limit, and observable lifecycle.

Good queue candidates

  • Transactional email and batched notifications
  • AI reports with provider rate and cost limits
  • Image, video, PDF, and import processing
  • Verified webhook fan-out and reconciliation
  • Scheduled cleanup or report generation

Keep in the request

  • Authentication and authorization decisions
  • Validation needed to accept the command
  • Fast authoritative database changes
  • Data required to render the immediate response
  • Work whose failure must reject the request

Next.js 16 also provides after(), which schedules code after a response or prerender finishes, and self-hosted next start waits for pending callbacks during graceful shutdown. That is useful for bounded post-response work such as logging. It is not a general durable queue: it does not replace persistent delivery, independent workers, retry/backoff state, workload smoothing, or queue observability.

Diagram 2: Queue decisionWork belongs in a queue when it can finish after the response and benefits from retries, smoothing, scheduling, or controlled concurrency.
Must finish now?Yes → request path
Can finish later?Slow, retryable, bursty, or scheduled → queue

Producer, Redis Queue, and Worker

The producer is trusted server-only Next.js code. BullMQ stores and coordinates jobs in Redis. A worker is a long-running Node process that imports the handler and listens to the exact same queue name. The browser never connects to Redis, and the worker normally exposes no public HTTP port.

Diagram 3: Producer → queue → workerA browser calls an authorized Next.js boundary, which adds a validated job to BullMQ in private Redis; a separate worker claims it and calls approved dependencies.
BrowserNext.js producerBullMQ + RedisWorkerProvider / DB
Web producer sending compact jobs to a central Redis queue where isolated email, AI, and document workers process them
Separate the handoff from execution. The queue smooths bursts while worker capacity stays controlled.

Redis and BullMQ

Redis is infrastructure, not a complete job system by itself. BullMQ adds claiming, locks, retries, delayed jobs, concurrency, events, progress, and cleanup semantics. This guide uses BullMQ's Redis backend. Current BullMQ v6 also provides Redis client adapters and a PostgreSQL backend abstraction, so read the documentation for the exact major version you adopt rather than mixing v5 and v6 APIs.

Do not assume cache Redis and queue Redis have identical requirements. A cache may permit eviction and no persistence; important jobs may require noeviction, reviewed AOF/RDB persistence, replication, recovery tests, adequate memory, and a dedicated failure domain.

ChoiceBest fitOperational review
BullMQ + RedisNode workers, retries, scheduling, concurrencyRedis durability, memory, connections, worker deployment
Managed workflow/queuePlatform-native delivery and scalingRuntime limits, delivery model, portability, cost
Database outboxAtomic business write plus eventual dispatchDispatcher, polling/CDC, retention, ordering

Install and Configure the Queue

In a real Next.js repository, pin reviewed versions using its existing package manager. The example below uses BullMQ's default Redis connection options; current BullMQ can also use supported Redis client adapters, whose reconnect and duplicate() behavior must meet worker requirements. Keep shared connection configuration in server/worker code, validate environment at startup, and never use a NEXT_PUBLIC_ queue credential.

Terminal (example only)
npm install bullmq zod
lib/queue/connection.ts
import type { ConnectionOptions } from 'bullmq'

const redisUrl = process.env.REDIS_URL
if (!redisUrl) throw new Error('REDIS_URL is required')

const url = new URL(redisUrl)
const baseConnection: ConnectionOptions = {
  host: url.hostname,
  port: Number(url.port || 6379),
  username: url.username || undefined,
  password: url.password || undefined,
  tls: url.protocol === 'rediss:' ? {} : undefined,
}

export const producerConnection: ConnectionOptions = {
  ...baseConnection, maxRetriesPerRequest: 1,
}
export const workerConnection: ConnectionOptions = {
  ...baseConnection, maxRetriesPerRequest: null,
}
Connection rule

A manually supplied ioredis client used by BullMQ workers must use maxRetriesPerRequest: null so blocking consumers can reconnect. An HTTP producer should fail within a bounded time instead of making a caller wait indefinitely. Current BullMQ also supports client adapters; configure retry and duplication behavior according to the selected adapter and provider.

Create a Queue and Add Jobs

lib/queue/contracts.ts
export const REPORT_QUEUE = 'reports-v1'

export type ReportJob = {
  schemaVersion: 1
  reportId: string
  accountId: string
}
lib/queue/report-queue.ts
import 'server-only'
import { Queue } from 'bullmq'
import { producerConnection } from './connection'
import { REPORT_QUEUE, type ReportJob } from './contracts'

export const reportQueue = new Queue<ReportJob>(REPORT_QUEUE, {
  connection: producerConnection,
  defaultJobOptions: {
    attempts: 4,
    backoff: { type: 'exponential', delay: 2_000 },
    removeOnComplete: { age: 86_400, count: 1_000 },
    removeOnFail: { age: 604_800, count: 5_000 },
  },
})
app/api/reports/route.ts
import { z } from 'zod'
import { reportQueue } from '@/lib/queue/report-queue'

const inputSchema = z.object({ reportId: z.string().uuid() }).strict()

export async function POST(request: Request) {
  const user = await requireUser(request)
  const parsed = inputSchema.safeParse(await request.json())
  if (!parsed.success) return Response.json({ error: 'Invalid request' }, { status: 400 })

  await requireReportAccess(user, parsed.data.reportId)
  const task = await createPendingTask(user.accountId, parsed.data.reportId)
  await reportQueue.add('generate-report', {
    schemaVersion: 1,
    reportId: parsed.data.reportId,
    accountId: user.accountId,
  }, { jobId: `report-${task.id}` })

  return Response.json({ taskId: task.publicId, status: 'queued' }, { status: 202 })
}
Diagram 4: Adding a jobThe route authenticates, validates, authorizes, records a public task, adds a bounded payload, and returns HTTP 202 with an opaque status identifier.
AuthenticateValidateAuthorizeEnqueue202 + task ID

Design Safe Job Payloads

A job payload is durable data that may appear in Redis, dashboards, logs, backups, and failed-job tooling. Store stable identifiers and a schema version, then load authorized current data in the worker. Do not store passwords, tokens, raw authorization headers, provider API keys, full private documents, or large binary files. Put files in object storage and enqueue an opaque object identifier.

Diagram 5: Safe payload boundariesA safe job contains a schema version and opaque resource identifiers; secrets, large files, raw personal data, and browser-controlled instructions stay outside the queue.

Create a Separate Worker

workers/report-worker.ts
import { Worker } from 'bullmq'
import { z } from 'zod'
import { workerConnection } from '../lib/queue/connection'
import { REPORT_QUEUE } from '../lib/queue/contracts'

const jobSchema = z.object({
  schemaVersion: z.literal(1), reportId: z.string().uuid(), accountId: z.string().uuid(),
}).strict()

const worker = new Worker(REPORT_QUEUE, async (job) => {
  const payload = jobSchema.parse(job.data)
  const claimed = await claimTaskOnce(job.id, payload.accountId)
  if (!claimed) return { status: 'already-processed' as const }
  await job.updateProgress(20)
  const result = await generateReport(payload.reportId, payload.accountId)
  await saveReportAndCompleteTask(job.id, result)
  await job.updateProgress(100)
  return { status: 'completed' as const }
}, { connection: workerConnection, concurrency: 4 })

worker.on('failed', (job, error) => {
  console.error('Report job failed', { jobId: job?.id, name: error.name })
})

async function shutdown(signal: string) {
  console.info('Closing worker', { signal })
  await worker.close()
}
process.once('SIGTERM', () => void shutdown('SIGTERM'))
process.once('SIGINT', () => void shutdown('SIGINT'))

The handler validates the durable payload again because queued data is still an input boundary. The worker receives only the database and provider permissions it needs. Logs contain an internal job ID and error category, never connection URLs, tokens, payload contents, or sensitive provider responses.

Job States, Retries, and Backoff

A typical job moves from waiting to active and then completed. A transient failure can move it back to a delayed retry; a permanent error or exhausted attempt count ends in failed state. BullMQ retains or removes records according to explicit policy. “Exactly once” should not be assumed: distributed workers provide at-least-once behavior in practical failure cases.

Diagram 6: Job lifecycleA waiting job becomes active; success completes it, while retryable failure delays another attempt and exhausted or permanent failure becomes observable failed work.
WaitingActiveCompletedFailed / retry
Diagram 7: Bounded exponential backoffFour attempts wait progressively longer between transient failures; a permanent validation error fails immediately and exhausted retries alert operators.
Attempt 1Immediate
Attempt 2Wait 2s
Attempt 3Wait 4s
Attempt 4Wait 8s
ExhaustedRetain + alert
Permanent errorDo not retry

Retry network timeouts, temporary rate limits, and recoverable provider failures. Do not blindly retry invalid payloads, revoked access, unsupported file formats, or hard quota failures. Honor Retry-After when supported, add jitter for large fleets, and cap both attempts and total time.

Job IDs and Idempotency

A deterministic job ID can suppress duplicate additions while the original job exists, but it does not make side effects idempotent. A worker may send an email and crash before completion is recorded. Use an application idempotency key, a unique database constraint or atomic state transition, and provider-supported idempotency where available. Define ordering explicitly; multiple workers and retries can reorder independent jobs.

Concurrency and Horizontal Scaling

BullMQ concurrency lets one worker process several asynchronous jobs. It helps I/O-bound work but does not create more CPU. CPU-heavy image or PDF work should use worker threads, child processes, or specialized services with conservative limits. Calculate database, Redis, memory, file-descriptor, and provider capacity before increasing replicas.

Diagram 8: Worker concurrencyOne worker with concurrency four can overlap four I/O-bound jobs, while CPU-heavy tasks need separate compute capacity rather than a large concurrency number.
Worker processconcurrency: 4
Job AAPI wait
Job BDB wait
Job CStorage wait
Job DEmail wait
CPU jobIsolate capacity
Diagram 9: Horizontal worker scalingSeveral identical worker replicas claim jobs from one queue; capacity grows without publishing a worker endpoint, while Redis and downstream limits remain shared constraints.
Shared queueWorker AWorker BWorker CControlled providers

Email, AI, Media, and Webhook Patterns

Email jobs

Enqueue a template name, recipient reference, locale, and idempotency key. Load current recipient data in the worker, suppress unsubscribed recipients, and avoid placing HTML, tokens, or credentials in Redis.

AI generation

Authorize and reserve budget before enqueueing. Limit concurrency by account and provider, enforce timeouts and output bounds, store results outside Redis, and distinguish safety rejection from transient provider failure.

Images and PDFs

Upload to object storage first, enqueue an object ID, scan and validate formats, restrict decompression and memory, process in an isolated workspace, then store a new immutable output reference.

Webhooks

Verify the signature against the raw request body before trusting or queueing an event. Record the provider event ID uniquely, respond quickly, and make downstream handling idempotent.

Diagram 10: Cost-controlled AI queueAn authorized request reserves budget, queues a compact prompt reference, waits behind a provider-aware limiter, and stores the generated result outside Redis.
AuthorizeReserve budgetAI queueLimited workerObject storage
Diagram 11: Secure webhook queueThe route preserves the raw body, verifies the provider signature, records the unique event, queues trusted work, and lets an idempotent consumer update business state.
ProviderVerify signatureDeduplicate eventQueueConsumer

Delayed and Scheduled Jobs

delay is useful for one future attempt, such as a short follow-up. BullMQ v6 removed the legacy repeat APIs, so repeated schedules use the current Job Scheduler API. Schedulers still need one logical owner, timezone and daylight-saving decisions, catch-up behavior, overlap prevention, and idempotency. Next.js or a host can trigger cron-like endpoints, but cron is a trigger—not a durable execution engine.

Current BullMQ Job Scheduler
await reportQueue.upsertJobScheduler(
  'daily-account-reports-v1',
  { pattern: '0 15 3 * * *', tz: 'UTC' },
  {
    name: 'generate-scheduled-reports',
    data: { schemaVersion: 1, reportId, accountId },
    opts: { attempts: 4, backoff: { type: 'exponential', delay: 2_000 } },
  },
)

A Job Scheduler produces the next delayed job when the preceding one begins processing, so a busy queue or insufficient worker capacity can make runs less frequent than the nominal interval. Monitor scheduler lag instead of treating it as a wall-clock guarantee.

Expose Safe Job Status

Do not give clients raw BullMQ job IDs, stack traces, payloads, or internal failure reasons. Store an application-owned task row with an opaque public ID, account ownership, safe state, bounded progress, and a user-facing error category. An authorized Route Handler can return queued, running, completed, or failed. Polling is often sufficient; Server-Sent Events or WebSockets add connection and authorization work.

queuedrunning 40%completedfailed safely

Database and Queue Consistency

A database commit and Redis enqueue are separate operations. If the write succeeds but enqueue fails, the application can lose required work. The transactional outbox pattern writes the business change and an outbox row in one PostgreSQL transaction. A dispatcher later publishes unsent rows, records delivery, and retries safely. The consumer still remains idempotent.

Diagram 12: Transactional outboxOne PostgreSQL transaction saves business data and an outbox record; a dispatcher publishes it to Redis and an idempotent worker applies the external side effect.
Business commandDB + outbox commitDispatcherRedis queueIdempotent worker

Monitoring, Failed Jobs, and Backpressure

Monitor queue depth, oldest waiting age, job duration percentiles, throughput, retry rate, exhausted jobs, stalled jobs, active concurrency, event-loop lag, Redis latency and memory, worker restarts, downstream errors, and business completion rate. Queue depth alone can look healthy while one old tenant job is stuck.

Retain failed jobs long enough to diagnose and replay safely, but cap count and age. Alert on sustained age, failure ratio, and stalled processing. A dead-letter queue is useful only with an owner, redrive procedure, payload retention policy, and protection against replaying permanent or unsafe failures.

Diagram 13: BackpressureWhen arrivals exceed safe worker throughput, queue age and depth grow; admission limits, batching, controlled scaling, and degraded features protect Redis and downstream providers.
Arrival spikeDepth + age growAdmission controlScale safelyRecover

Graceful Worker Shutdown

On SIGTERM, stop accepting new jobs, allow active handlers to finish within the platform grace period, close the BullMQ worker, then exit. Make Kubernetes, Compose, or the process manager grace period longer than normal job completion or design checkpoints for longer work. A forced kill can lead to another delivery, which is why idempotency remains mandatory.

Diagram 14: Graceful shutdownA termination signal stops new claims, lets active jobs settle, closes BullMQ connections, and exits before the orchestrator deadline; forced interruption remains retry-safe.
SIGTERMStop claimsFinish activeClose workerExit

Docker Production Architecture

Build the web app and worker from the same reviewed source and immutable image, then select a different command. The web service joins the public proxy network and private application/data networks. The worker joins only private networks. Redis and PostgreSQL publish no host ports. The worker publishes no port at all.

compose.yaml (architecture example)
services:
  app:
    image: registry.example/app:${APP_IMAGE_TAG:?required}
    command: ["node", "server.js"]
    networks: [proxy, app_private, data_private]
  worker:
    image: registry.example/app:${APP_IMAGE_TAG:?required}
    command: ["node", "dist/workers/report-worker.js"]
    restart: unless-stopped
    stop_grace_period: 60s
    networks: [app_private, data_private]
    # No ports: worker is not a public service.
  redis:
    image: redis:7.4-alpine
    command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
    volumes: [redis_data:/data]
    networks: [data_private]
  postgres:
    image: postgres:17-alpine
    volumes: [postgres_data:/var/lib/postgresql/data]
    networks: [data_private]
networks:
  proxy: { external: true }
  app_private: { internal: true }
  data_private: { internal: true }
volumes:
  redis_data:
  postgres_data:

The example is not copied into a live stack because this repository has none. Pin image digests or approved versions, supply secrets through deployment configuration, add real health/readiness behavior, test backup and recovery, and compile worker TypeScript as part of the image build. Do not run TypeScript directly in production without an intentional supported runtime.

Diagram 15: Docker production stackOnly Traefik accepts public HTTPS; the Next.js app produces jobs, private workers consume them, Redis coordinates queues, and PostgreSQL remains authoritative.
TraefikPublic :443
Next.js appProducer
Worker replicasNo ports
RedisPrivate queue
PostgreSQLPrivate data
ProvidersEgress only
Diagram 16: Network segmentationThe edge network contains Traefik and the app; private application and data networks contain workers, Redis, and PostgreSQL, with no direct internet path to stateful services.
Edge networkInternet → Traefik → app
Private networksapp/worker → Redis/PostgreSQLNo public Redis or worker port
Secure production cutaway with public gateway and web application separated from private Redis, PostgreSQL, and horizontally scaled worker containers
Scale the worker independently. Private networking and separate resource limits contain failures and protect stateful dependencies.

Production Security Checklist

  • Keep REDIS_URL server-only and out of logs.
  • Publish neither Redis nor worker ports.
  • Authenticate and authorize every enqueue operation.
  • Validate strict, versioned payloads at producer and worker.
  • Keep secrets and sensitive content out of jobs.
  • Use least-privilege database, storage, and provider credentials.
  • Verify webhook signatures before trusting events.
  • Redact provider errors returned to clients.
  • Bound retries, job size, runtime, and concurrency.
  • Enforce provider rate limits and cost budgets.
  • Review Redis persistence and maxmemory policy.
  • Retain failed jobs under a privacy-aware policy.
  • Patch Redis, Node, BullMQ, and base images.
  • Audit replay and administrative dashboard access.

Troubleshooting

SymptomCheckSafe action
Jobs remain waitingWorker process, exact queue name, Redis reachabilityRestore worker; do not delete jobs
Jobs become stalledEvent-loop blocking, process kills, lock renewalReduce CPU blocking; inspect idempotency
Retry stormPermanent errors, provider outage, missing jitterPause admission and fix retry classification
Duplicate effectHandler transaction and provider idempotencyAdd durable idempotency, then replay carefully
Redis memory pressureRetention, payload size, queue age, eviction policyStop unsafe admission; add capacity and cleanup policy
Slow web requestsEnqueue timeout and Redis connection exhaustionBound producer failure; repair capacity
Diagram 17: Worker-not-processing diagnostic pathStart with process health, then match queue name and Redis connection, inspect active or stalled jobs, and finally verify handler dependencies and resource limits.
Worker running?No → start and inspect exit
Queue names match?No → fix versioned name
Redis reachable?No → network/TLS/auth
Job active or stalled?Inspect CPU, locks, dependencies

Common Next.js Background Job Mistakes

Fire-and-forget in a request

An unawaited promise is not a durable job. Process shutdown can discard it.

Homemade Redis list

A list alone does not provide safe claim, retry, lock, scheduling, or observation semantics.

Secrets or files in payloads

Jobs are durable operational data. Store opaque references and load protected data when needed.

Infinite retries

Permanent failure can create cost, noise, and provider pressure. Bound and classify retries.

Non-idempotent effects

Redelivery can duplicate emails, charges, webhooks, and database transitions.

Uncontrolled concurrency

Workers can exhaust PostgreSQL, Redis, memory, and third-party quotas.

Public worker or Redis

Neither needs a public port in the normal producer/consumer architecture.

No version strategy

Old jobs can survive a deploy. Version payloads and keep compatible consumers during rollout.

Next.js Background Job Best Practices

  • Queue only work that can safely finish after the response.
  • Use a maintained queue or workflow product instead of inventing reliability primitives.
  • Run consumers separately from Next.js web processes.
  • Validate, authorize, minimize, and version every payload.
  • Make every externally visible side effect idempotent.
  • Retry only transient failures with bounded backoff and jitter.
  • Control concurrency from measured downstream capacity.
  • Use object storage for files and PostgreSQL for authoritative task state.
  • Use an outbox where a database write must reliably cause a job.
  • Keep Redis private with intentional persistence and eviction.
  • Design graceful shutdown and deploy compatibility.
  • Monitor age, depth, duration, failures, stalls, capacity, and business outcomes.

Frequently Asked Questions

What is a background job in Next.js?

It is slow, retryable, or non-interactive work that a Next.js server records for asynchronous processing instead of completing inside the HTTP request.

Does Next.js have a built-in job queue?

No general durable queue is built into Next.js. A deployment platform may offer scheduled functions or workflow products, while self-hosted applications can use a maintained queue such as BullMQ.

When should I use background jobs?

Use them for email, imports, webhooks, media processing, AI generation, reports, and other work that can outlive a request and benefits from retries or controlled concurrency.

When should I avoid a queue?

Avoid one for fast request-critical validation, simple database writes, work that must finish before the response, or systems that cannot operate another stateful dependency.

Can Redis be used for Next.js background jobs?

Yes. A queue library can use Redis for durable coordination between Next.js producers and separate worker processes. Do not build reliability semantics with a casual Redis list.

What is BullMQ?

BullMQ is a Node.js queue library with jobs, workers, retries, backoff, delayed work, concurrency, events, progress, and cleanup controls. This guide uses its Redis backend; current BullMQ also defines backend and client adapter interfaces.

How does BullMQ work with Next.js?

Server-only Next.js code validates and adds a small job payload. A separate Node worker using the same queue name and Redis deployment claims and processes that job.

Should the worker run inside my Next.js server?

Usually no. Run queue consumers as a separate process or service so web deploys, scaling, shutdown, resource limits, and failures remain independent.

Can I use the same Redis for caching and queues?

It is technically possible, but cache eviction and disposable durability assumptions can threaten important jobs. Review persistence, maxmemory, eviction, isolation, capacity, and failure domains.

How do I retry failed jobs?

Configure a bounded attempts count and a suitable backoff policy when adding the job. Retry only transient failures and keep handlers idempotent.

What is exponential backoff?

It increases the wait between attempts, reducing pressure on an unhealthy dependency. Add jitter when many jobs could retry together and honor provider retry guidance.

Why must background jobs be idempotent?

Delivery is generally at least once. A worker can fail after causing an external effect but before recording completion, so a repeated attempt must not duplicate the effect.

How do I show job progress in the UI?

Return an opaque public task ID, expose an authorized status endpoint backed by application-owned state, and poll or stream safe status without exposing queue internals.

How do I run workers with Docker Compose?

Build app and worker from the same immutable image, give the worker a distinct command, connect it only to required private networks, and publish no worker port.

How do I secure a background worker?

Keep it private, validate versioned payloads, minimize credentials, authorize enqueue operations, verify webhook signatures before enqueueing trusted work, and redact logs.

Current Official References

Next Steps

You now have a production model for asynchronous work: a trusted Next.js producer, a durable Redis-backed queue, separately deployed BullMQ workers, safe payloads, idempotent effects, bounded retries, controlled concurrency, and observable failures. Monitor that complete stack in Blog #24: Next.js 16 Monitoring & Observability.

WhatsApp