Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

How to Run Background Jobs in Your Next.js SaaS -- Process Async Tasks Without Bull or Inngest

October 4, 2026
nextjsneon-dbdrizzle-ormsaasperformance

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.

Why Blocking API Routes Hurts

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.

A Decision Framework for Async Tasks

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.

Pattern 1: Fire and Forget After Response

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.

Pattern 2: A Database-Backed Job Queue

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.

Pattern 3: Show the User Progress

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:

  1. Enqueue the job, return the jobId to the client immediately
  2. The client connects to GET /api/jobs/[id]/status -- an SSE endpoint
  3. The cron worker writes status and progress to the jobs table
  4. The SSE endpoint polls the row every second and streams changes

The user sees "Processing... 45%... Done" without a WebSocket server, without a new service, and without blocking the original HTTP request.

When a Real Job Queue Makes Sense

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:

  • Users expect sub-five-second job starts
  • You have more than six distinct worker types
  • You need branching workflows where step B depends on the output of step A

None of these are day-one problems. Ship the simple version, learn from real usage, and upgrade only when the metrics tell you to.

Start Simple, Scale When You Need 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.