How to Run background jobs on Vercel without a queue

How to Run background jobs on Vercel without a queue

Table of contents

The problems

I recently needed to solve the following problems with my Next.js application deployed on Vercel:

  1. I needed my job-accepting API route to return a response quickly.
  2. I had expensive and slow work to perform in the background for each new job.
  3. Time is not unlimited. Vercel capped function invocation timeouts at 5 minutes for Pro plan users. I didn't want to risk doing so much work in one API route that it was likely I'd hit the timeout. I wanted to divvy up the work.

The solution

The solution is to use a fire and forget function. Forget, in the sense that we're not awaiting the response from the long-running process.

Here's what my fire-and-forget function looks like in my app Panthalia:

import Post from '../types/posts';

export async function startBackgroundJobs(post: Post) {
  
  const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000';

  try {
    await fetch(`${baseUrl}/api/jobs`, {
      headers: {
        'Content-Type': 'application/json',
      },
      method: 'POST',
      body: JSON.stringify(post),
    }).then(() => {
      console.log(`Finished updating existing post via git`)
    })
  } catch (error) {
    console.log(`error: ${error}`);
  }
}

This is a React Server Component (RSC) by default because it does not include the 'use client' directive at the top of the file, and because Next.js routes default to being server components.

Because this is a server component, we actually have to determine the ${baseUrl} for our API call - whereas you may be familiar with calling fetch on a partial API route like so:

await fetch('/api/jobs', {})

in our server component we must supply a fully qualified URL to fetch.

Calling functions that call other API routes

The startBackgroundJobs function is really just an API call to a separate route, /api/jobs which POSTs to that route the information about the new post including its ID.

Everything that other route needs to start processing work in a separate invocation. Meanwhile, the startBackgroundJobs call itself is quick because it's making a request and returning.

This means the API route can immediately return a response to the client after accepting the new task for processing:

import { sql } from '@vercel/postgres';
import { NextResponse } from 'next/server';
import { startBackgroundJobs } from '../../lib/jobs'
import Post from "../../types/posts";

import { getServerSession } from "next-auth/next"
import { authOptions } from '../../lib/auth/options'
import { imagePrompt } from '../../types/images';

// This route accepts new blog posts created by the Panthalia client
// It calls a function, `startBackgroundJobs` which itself calls a separate 
// API route, passing the information necessary to continue processing 
// long-running jobs that will operate on the post data
// Meanwhile, the client receives a response immediately and is freed up to
// create more posts
export async function POST(request: Request) {
  try {
    const session = await getServerSession(authOptions)

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
    }

    const formData = await request.json()

    const {
      title,
      slug,
      summary,
      content,
      ...formImagePrompts
    } = formData

    // Query to insert new blog post into the database
    const result = await sql`
      INSERT INTO posts(
      title,
      slug,
      summary,
      content,
      status
    )
      VALUES(
      ${title},
      ${slug},
      ${summary},
      ${content},
      'drafting'
    )
      RETURNING *;
    `;

    // Save the postId so we can use it to update the record with the pull request URL once it's available
    const newPost: Post = {
      id: result.rows[0].id,
      title,
      slug,
      summary,
      content,
      gitbranch: null,
      githubpr: null,
    }

    const promptsToProcess = formImagePrompts.imagePrompts as imagePrompt[]

    // Query to insert images into the database
    for (const promptToProcess of promptsToProcess) {
    
      const imgInsertResult = await sql`
        INSERT INTO 
        images(
          post_id,
          prompt_text)
         VALUES(
          ${newPost.id},
          ${promptToProcess.text}
        )
      `
    }

    // Fire and forget the initial post setup (git operations) and the image generation tasks
    startBackgroundJobs(newPost);

    // Because we're not awaiting the response from the long-running job, we can immediately return a response to the client 
    return NextResponse.json({ result, success: true }, { status: 200 });

  } catch (error) {

    console.log(`error: ${error} `);

    return NextResponse.json({ error }, { status: 500 });

  }
}

Wrapping up

And there you have it. Using this pattern, you can return a response to your client immediately to keep your application quick and responsive, while simultaneously handling longer-running jobs in a separate execution context.