Your user clicks "Generate Report." Your API route starts querying the database, crunching numbers, and building a PDF. Three seconds pass. Five seconds. The browser shows a spinner. The user refreshes. Now the job runs twice.
Most SaaS founders run into this problem the first time a task takes longer than a page load. The two instincts that both cause pain: block the response and hope users wait, or reach for a dedicated job queue like Inngest or BullMQ before you even have paying customers.
There is a third path. It uses tools you already have -- your Neon DB, Drizzle ORM, and Vercel Cron -- and it handles 90% of async needs without a new service.
Every Next.js API route has a timeout. On Vercel's Hobby plan it is 10 seconds, on Pro it is 60 seconds for serverless functions. Slow synchronous tasks -- sending 50 emails, processing an uploaded file, calculating cohort analytics -- will hit those limits before your product grows enough to need a dedicated queue.
Worse, blocking routes couple the user's experience to the slowest thing in your system. A user should not stare at a spinner while your server talks to three APIs.
Before reaching for a job queue, ask three questions: does this task need to retry on failure? Does it need to run many times in parallel? Does the user need to see its status?
For most early-stage SaaS tasks the answer is "not yet." Here is the practical breakdown:
1. Fire and forget, no retry needed -- A welcome email, a Slack alert, a cache bust. Use a background Promise and let the Vercel function finish it after responding to the user.
2. Retry on failure, no real-time status -- Processing a file upload, sending a batch of transactional emails, syncing to a third-party API. Use a lightweight database-backed queue with your existing Drizzle ORM and Neon setup.
3. Long-running, user watches progress -- Report generation, AI batch processing, large data imports. Store a status field on the job record and stream updates to the client via SSE.
4. High volume, strict latency SLA -- Only then add Inngest or BullMQ. This is a scale problem. Do not solve it before you have it.
Vercel's Node.js runtime keeps the function alive after you send a response until all pending Promises resolve. You can use this for lightweight tasks:
// app/api/posts/route.ts
export async function POST(req: Request) {
const post = await postService.create(data);
// respond immediately, notification sends after
notifySubscribers(post.id).catch(console.error);
return Response.json(post, { status: 201 });
}
Never await the background call inside the route. Always attach a .catch() so a failed notification does not crash the response. This works for single tasks that rarely fail and do not need retries.
When you need retry logic, use your Neon DB as a lightweight queue. Add a jobs table to your Drizzle schema:
// modules/job/job.schema.ts
export const jobTable = pgTable('jobs', {
id: uuid('id').defaultRandom().primaryKey(),
type: text('type').notNull(),
payload: jsonb('payload').notNull(),
status: text('status').notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
max_attempts: integer('max_attempts').notNull().default(3),
run_at: timestamp('run_at').notNull().defaultNow(),
created_at: timestamp('created_at').notNull().defaultNow(),
});
A Vercel Cron endpoint running every minute claims pending jobs, executes the handler, and marks them done or increments the failure count.
Your API routes enqueue work in one line and return immediately:
await jobRepo.enqueue('send_report_email', { userId, reportId });
return Response.json({ status: 'queued' }, { status: 202 });
The user gets an instant 202. The email arrives within a minute. Failed jobs retry automatically up to max_attempts. This pattern builds directly on the Next.js SaaS Boilerplate -- the Drizzle migration tooling, service layer, and Vercel Cron setup are already there.
This is also exactly how onboarding email sequences and weekly digest emails work under the hood -- cron picks up pending work on a schedule and executes it.
For tasks where the user needs to watch what is happening -- a CSV import, an AI document generation, a bulk operation -- combine the database job queue with real-time status updates via Server-Sent Events.
The workflow:
jobId to the client immediatelyGET /api/jobs/[id]/status -- an SSE endpointjobs tableThe user sees "Processing... 45%... Done" without a WebSocket server, without a new service, and without blocking the original HTTP request.
If you find yourself managing a dozen job types, need tasks to start within seconds of being enqueued, or want durable step-by-step workflows, it is time to add Inngest or Redis-backed BullMQ.
The signal is clear: your cron worker file is hard to read, or the one-minute Vercel Cron interval is too slow for your use case. Neither happens before you have a few hundred active users.
Signs you have outgrown the DB queue:
None of these are day-one problems. Ship the simple version, learn from real usage, and upgrade only when the metrics tell you to.
The pattern that works for most SaaS founders: fire-and-forget for notifications, the DB queue for anything that needs retry, SSE for anything the user watches.
You can add the jobs table to the Next.js SaaS Boilerplate in one migration and one cron route. No new accounts, no new infrastructure, no new monthly bills.
Ship the feature. Add infrastructure when you have the problem -- not when you imagine it.