The problem with long running code in Next serverless functions#
The current design paradigm at the time of this writing is called App Router.
Next.js and Vercel provide a simple mechanism for writing and deploying cloud functions that expose HTTP endpoints for your frontend site to call. However, sometimes you want to asynchronously do work on the backend in a way that doesn’t block a frontend caller, needs to move on. You could fire and forget the call from the frontend, but this is often not safe when running in a serverless environment. The following approach uses two server-side API endpoints to run an asynchronous function from the perspective of the frontend caller.
โฏ npx create-next-app@latest
โ What is your project named? โฆ async-project
โ Would you like to use TypeScript? โฆ No / Yes
โ Would you like to use ESLint? โฆ No / Yes
โ Would you like to use Tailwind CSS? โฆ No / Yes
โ Would you like to use `src/` directory? โฆ No / Yes
โ Would you like to use App Router? (recommended) โฆ No / Yes
โ Would you like to customize the default import alias? โฆ No / Yes
Creating a new Next.js app in /Users/danielcorin/dev/async-project.
Running the app, we can see the starter page
npm run dev
Now, we’ll create an API endpoints. From the project root:
mkdir -p src/app/api/submit
cd src/app/api/submit
touch route.ts
The folder can have any name, but I like this convention for separating API routes from pages.
The folder path is the same url used to hit the API in the browser or with curl
.
In route.ts
, let’s write a simple HTTP handler for a POST
to /api/submit
.
In src/app/api/submit/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const data = await request.json()
await new Promise(resolve => setTimeout(resolve, 3000))
console.log("3 seconds passed");
return NextResponse.json(data)
}
Now curl
the endpoint:
curl -X POST -H "Content-Type: application/json" -d '{"key1":"value1","key2":"value2"}' http://localhost:3000/api/submit
We see a 3 second delay, then the following prints in the terminal running the Next app
3 seconds passed
and the payload is echoed back to the curl
command
{"key1":"value1","key2":"value2"}
A possible solution#
Now, let’s imagine our caller is constrained in such a way that it can’t wait for 3 seconds.
It needs to be able to call /api/submit
, know the information was received by the server, then move on.
We could attempt to do the work in a promise that we don’t await
.
However, once the function returns (and the underlying Lambda execution exit), we don’t know if the promise will ever execute, which could lead to nondeterministic behavior or the code never running at all.
To resolve this challenge, we’re going to create another route that will run our code we want to execute asynchronously from the perspective of the caller, but synchronously in the context of the Lambda function, such that it will not exit before the code is finished running.
From the project root
mkdir -p src/app/api/job
cd src/app/api/job
touch route.ts
In src/app/api/job/route.ts
, we write
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const data = await request.json()
await new Promise(resolve => setTimeout(resolve, 3000))
console.log("3 seconds passed");
return NextResponse.json(data)
}
We also modify src/app/api/submit/route.ts
to read
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const data = await request.json()
fetch(`http://localhost:3000/api/job`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
return NextResponse.json(data)
}
Running the same curl as before returns immediately
โฏ curl -X POST -H "Content-Type: application/json" -d '{"key1":"value1","key2":"value2"}' http://localhost:3000/api/submit
{"key1":"value1","key2":"value2"}
Three seconds later, the following prints in the Next app:
3 seconds passed
We’ve managed to unblock the first API call but can still be confident that long-running code executes in Lambda before the function exits. This approach doesn’t quite work in production through, since we’ve hardcoded the url to localhost. We can do a small refactor to make this work in production as well.
Create an .env.local
file in the project root:
VERCEL_URL="localhost:3000"
which will allow the project to continue to work locally.
Vercel sets VERCEL_URL
automatically in projects.
Update src/app/api/submit/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const data = await request.json()
const url = `${getUrl()}/api/job`
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
return NextResponse.json(data)
}
function getUrl(): string {
const envVar = process.env.VERCEL_URL
if (!envVar) {
return ""
}
if (envVar.includes("localhost")) {
return `http://${envVar}`
} else {
return `https://${envVar}`
}
}
These code changes check VERCEL_URL
and construct the url depending on the environment.
In Vercel, VERCEL_URL
should look something like async-project-q0n3qzlm7-danielcorin.vercel.app
, which should help the above code make a bit more sense.
The value of VERCEL_URL
changes on each deploy, but this is ok since our second HTTP from the first serverless function calls back into the same version of the app.