Push notifications from serverless functions
Serverless is different from a long-running server in one important way: when the response ends, the runtime may terminate the process. Un-awaited promises can be dropped.
Here’s how to handle notifications correctly in each major serverless environment.
The problem
On a traditional server, this works:
res.json({ status: "ok" })
fyi("New signup") // fires in the background, nobody waitsOn serverless, the runtime may kill the process immediately after res.json(). The notification is queued but never sent.
Vercel Functions
Vercel provides waitUntil via @vercel/functions:
import { waitUntil } from "@vercel/functions"
import fyi from "trigger.fyi"
export async function POST(req) {
const user = await createUser(await req.json())
const response = NextResponse.json(user)
// Runs after response is sent, keeps the function alive
waitUntil(fyi("New signup", { body: user.email, plan: user.plan }))
return response
}Alternatively, just await it — fyi() never throws and completes in one edge roundtrip:
export async function POST(req) {
const user = await createUser(await req.json())
await fyi("New signup", { body: user.email })
return NextResponse.json(user)
}The await adds ~100ms to the response. For most use cases, that’s fine. For latency-sensitive paths, use waitUntil.
Cloudflare Workers
Workers has ctx.waitUntil():
export default {
async fetch(req, env, ctx) {
const user = await createUser(req)
const response = new Response(JSON.stringify(user), {
headers: { "Content-Type": "application/json" }
})
ctx.waitUntil(
fyi("New signup", { body: user.email })
)
return response
}
}ctx.waitUntil() keeps the worker alive until the promise resolves, even after the response is returned. This is the canonical way to do fire-and-forget in Workers.
AWS Lambda
Lambda waits for the event loop to drain before freezing. If you don’t await, the promise may not complete before the next invocation reuses the container.
The safe pattern is await:
export const handler = async (event) => {
const result = await processEvent(event)
// await is safe here — Lambda won't return until this resolves
await fyi("Event processed", {
body: result.id,
type: event.type
})
return { statusCode: 200, body: JSON.stringify(result) }
}Or configure callbackWaitsForEmptyEventLoop = false if you need to return immediately:
export const handler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false
const result = await processEvent(event)
fyi("Event processed", { body: result.id }) // fire-and-forget
return { statusCode: 200, body: JSON.stringify(result) }
}With callbackWaitsForEmptyEventLoop = false, Lambda returns before the notification completes. This is the same tradeoff as waitUntil — the notification may not always be delivered if the container is immediately reclaimed.
Deno Deploy
import fyi from "npm:trigger.fyi"
Deno.serve(async (req) => {
const user = await createUser(req)
const response = new Response(JSON.stringify(user), {
headers: { "Content-Type": "application/json" },
})
// Deno.serve keeps the handler alive — await is safe
await fyi("New signup", { body: user.email })
return response
})The safest universal pattern
When in doubt, await fyi(). It:
- Never throws (errors are swallowed)
- Completes in one edge roundtrip (~50-150ms)
- Works on every runtime without platform-specific APIs
- Can’t fail your request
// Works everywhere
const response = buildResponse(result)
await fyi("Event processed", { body: result.id })
return responseThe latency cost is a network roundtrip. For non-latency-critical routes (webhooks, background triggers, form submissions), this is the right default.
Setup
npx trigger.fyiGenerates a key, subscribes your device. Add to your serverless environment’s secrets or env vars:
TRIGGER_FYI_SECRET_KEY=your_key_here
The SDK reads the key at call time — no module-level initialization, no global state. Safe to use in any serverless runtime.
Related: Push notifications from Cloudflare Workers · Push notifications from Next.js · Cron job notifications · What is trigger.fyi?