NestJS Background Jobs Not Running on Vercel Serverless Functions

NestJS background jobs not running on Vercel is one of those bugs that makes you doubt your own code, because locally the worker fires every job exactly on schedule, and in production it just... stops. Nothing crashes. No error in the logs. Jobs pile up in the queue and nobody's home to process them. The processor code is fine. Vercel's serverless functions simply have nowhere for a worker loop to live.
Short answer: a BullMQ (or any queue) worker needs a process that stays running continuously, and Vercel's serverless functions are designed to do the opposite — spin up, handle one thing, and be free to freeze or disappear immediately after. Vercel Cron Jobs trigger a function on a schedule; they don't replace a persistent worker. The fix is to move the worker itself to an always-on host and keep everything else — your API, your frontend — exactly where it already is on Vercel.

Why NestJS Background Jobs Silently Stop on Vercel
A BullMQ worker isn't a function that runs once and returns. It's a loop — it opens a connection to Redis, blocks waiting for the next job, processes it, and immediately goes back to waiting. That loop needs to exist somewhere for longer than the lifetime of a single request. Vercel's serverless functions are explicitly built around the opposite model: a function is invoked to handle one request, and the moment that response is sent, the platform is free to freeze the instance, reuse it for something else, or kill it outright. There's no contract that says your worker's while loop gets to keep running after the response goes out.
This is the same underlying mechanism behind WebSocket connections failing to stay open on Vercel — anything that needs a process to persist beyond a single request-response cycle runs into this wall, whether it's a socket or a worker loop. It's not a bug specific to queues. It's the platform being consistent about what it's designed to host.
(If you've been staring at your @Processor() class wondering if you botched the decorator — you didn't. The decorator's fine. The class just never gets a chance to keep running.)
Why Vercel Cron Jobs Aren't a Substitute for a Real Worker
Vercel Cron Jobs look like they might solve this — they trigger a serverless function on a schedule, which sounds worker-adjacent. They're genuinely useful for "run this specific task once a day" work. What they don't give you is a continuously running process pulling from a queue: no persistent connection, no automatic retries with backoff, no concurrency control across jobs, no draining a queue that's actively filling up in real time. A cron trigger fires a function once, that function runs, and it's gone again. If your actual need is "process jobs as they arrive, with retries and backoff," a scheduled trigger is solving a different problem than the one you have.

Moving the Worker to an Always-On Service
The fix is architectural, not a config change: run the worker as its own small service on a host that keeps one process alive — Railway, Render, Fly.io, or a container on Fargate all work.
1// worker-service/src/main.ts — a standalone NestJS app
2// that does nothing except process the queue
3import { NestFactory } from '@nestjs/core';
4import { WorkerModule } from './worker.module';
5
6async function bootstrap() {
7 const app = await NestFactory.createApplicationContext(WorkerModule);
8 // No HTTP listener needed — this process exists purely to run the worker
9}
10bootstrap();Your processor class doesn't change at all:
1// crm-sync.processor.ts — identical whether it runs on Railway or anywhere else
2import { Processor, WorkerHost } from '@nestjs/bullmq';
3import { Job } from 'bullmq';
4
5@Processor('crm-sync-queue')
6export class CrmSyncProcessor extends WorkerHost {
7 async process(job: Job): Promise<void> {
8 // Same business logic, now running somewhere that stays alive to execute it
9 }
10}Both @nestjs/bullmq on the NestJS side and BullMQ's own docs confirm the same expectation implicitly: a worker is meant to be a long-lived process, and neither library assumes otherwise. Nothing in your queue setup needs to change — only where the worker process itself is deployed.

Enqueuing From Vercel, Processing Elsewhere
The split is simple: your Vercel-hosted API keeps enqueuing jobs exactly as before, because adding a job to a queue is a normal request-response operation that fits Vercel's model fine.
1// notifications.service.ts — this still runs happily inside a Vercel function
2import { Injectable } from '@nestjs/common';
3import { InjectQueue } from '@nestjs/bullmq';
4import { Queue } from 'bullmq';
5
6@Injectable()
7export class NotificationsService {
8 constructor(@InjectQueue('notifications-queue') private queue: Queue) {}
9
10 async notifyUser(userId: string, payload: Record<string, unknown>) {
11 await this.queue.add('send-notification', { userId, ...payload });
12 }
13}Both the Vercel-hosted API and the separately-hosted worker point at the same Redis instance. The API's job is just "put this on the queue" — a fast, stateless action. The worker's job is "stay alive and keep pulling from it," which is exactly the part Vercel was never going to do for you.
The Opinion Part
Here's the position worth stating plainly: Vercel is an excellent choice for your frontend, and a genuinely fine choice for a stateless API layer. It is not a general-purpose backend host, and background workers are exactly where that boundary shows up first — right after WebSockets. Trying to force a persistent worker onto a platform built around ephemeral functions costs you more debugging hours chasing "why did this silently stop" than just standing up a second, small always-on service ever will. That second service doesn't need to be complicated. It needs to exist, and it needs to be honest about what it's for.
There's a cost dimension here too, worth naming plainly: teams sometimes try to work around this by bolting extra scheduled-function complexity onto Vercel rather than just running a $5-a-month worker elsewhere — more cron triggers, more retry logic reinvented at the application layer, more moving parts trying to simulate a persistent process that never actually persists. Given that 84% of organizations already call managing cloud spend their single biggest cloud challenge, and roughly 29% of cloud spend goes to waste (Flexera, 2024), adding complexity to avoid a small, honest, always-on service is exactly the kind of decision that quietly adds to that waste instead of avoiding cost.
Conclusion
If NestJS background jobs aren't running on Vercel, the processor code was never the problem — the platform simply has nowhere for a worker loop to live once a request finishes. Keep enqueuing from wherever it's convenient, including Vercel, and move the worker itself to an always-on host built to keep a process running. Once that split is in place, jobs stop mysteriously stalling and start doing the thing queues are supposed to do quietly and reliably in the background.
If you're setting this worker architecture up for the first time rather than migrating an existing one, our background job queue architecture guide covers retries, dead letter queues, and monitoring from scratch — and if you're still deciding between BullMQ and something else entirely, the BullMQ vs Agenda comparison is worth reading before you wire any of this up. Either way: the worker needs a home that doesn't freeze. Give it one, and stop checking the queue dashboard every hour wondering why it's backing up again.
Frequently Asked Questions
A BullMQ worker is fundamentally a long-running loop — it stays alive, holds a connection to Redis, and continuously pulls jobs off a queue. Vercel's serverless functions are the opposite by design: they spin up to handle one request, return a response, and are free to be frozen or torn down immediately after. There's no guarantee the process is still alive a second later to keep polling for the next job, so jobs silently stop being picked up.
Only for genuinely simple, infrequent, short-running tasks — Vercel Cron Jobs trigger a serverless function on a schedule, they don't run a persistent worker loop. They're a reasonable fit for 'run this once a day' tasks, but they're not a drop-in replacement for a BullMQ worker handling retries, backoff, concurrency, and continuous queue draining.
On any always-on host — Railway, Render, Fly.io, a small VM, or a container on Fargate. The worker doesn't need to be complicated; it just needs a process that Vercel never freezes. Your API and frontend can stay on Vercel exactly as they are; only the worker moves.
Nothing changes about how you enqueue jobs. Your Vercel-hosted NestJS API still calls queue.add() against the same Redis instance; it's just adding a job to a queue, which is a normal request-response operation that Vercel handles fine. The worker, running on the separate always-on host, is what pulls that job off the queue and processes it.
Yes, and often worse — BullMQ's repeatable jobs rely on a scheduler process staying alive to fire them on schedule. Without a persistent worker to host that scheduler, recurring jobs either never fire or fire unpredictably depending on which serverless invocation happens to be warm at the right moment. Recurring jobs need an always-on worker even more than one-off jobs do.
