Push notifications from Cloudflare Workers
Workers terminate immediately after the response is returned. Un-awaited promises are dropped. Here’s how to send notifications correctly.
The right pattern: ctx.waitUntil
ctx.waitUntil() keeps the worker alive after the response is sent, until the promise resolves:
import fyi from "trigger.fyi"
export default {
async fetch(req, env, ctx) {
// Process the request
const result = await handleRequest(req, env)
// Send response immediately
const response = new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" }
})
// Notify after response — worker stays alive until this resolves
ctx.waitUntil(
fyi("Request handled", {
body: result.id,
method: req.method,
path: new URL(req.url).pathname
})
)
return response
}
}The response lands on the client first. The notification fires in the background. The worker terminates after both complete.
With Hono
import { Hono } from "hono"
import fyi from "trigger.fyi"
const app = new Hono()
app.post("/signup", async (c) => {
const user = await createUser(await c.req.json())
const response = c.json(user)
c.executionCtx.waitUntil(
fyi("New signup", {
body: user.email,
plan: user.plan
})
)
return response
})
app.post("/webhooks/stripe", async (c) => {
const event = await verifyStripeWebhook(c)
if (!event) return c.text("Unauthorized", 401)
const response = c.text("OK")
if (event.type === "payment_intent.succeeded") {
const amount = (event.data.object.amount / 100).toFixed(2)
c.executionCtx.waitUntil(
fyi(`Payment: $${amount}`, {
body: event.data.object.receipt_email
})
)
}
return response
})
export default appReading the key from env
Workers don’t have process.env — bindings come from env:
export default {
async fetch(req, env, ctx) {
// Set TRIGGER_FYI_SECRET_KEY as a Worker secret or var
// The SDK reads process.env, but you can override:
const key = env.TRIGGER_FYI_SECRET_KEY
ctx.waitUntil(
fetch(`https://trigger.fyi/${key}`, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "Job complete"
})
)
return new Response("OK")
}
}Or set TRIGGER_FYI_SECRET_KEY as a Worker secret — Workers expose secrets as environment bindings, and the trigger.fyi SDK uses process.env.TRIGGER_FYI_SECRET_KEY via the nodejs_compat flag.
wrangler.toml setup
name = "my-worker"
main = "src/index.js"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
[vars]
# Non-secret env vars hereAdd the secret via wrangler:
wrangler secret put TRIGGER_FYI_SECRET_KEYThe nodejs_compat flag lets the SDK use process.env.
In a Durable Object
export class MyObject {
constructor(state, env) {
this.state = state
this.env = env
}
async fetch(req) {
const result = await this.processRequest(req)
this.state.waitUntil(
fyi("Object processed", { body: result.id })
)
return new Response(JSON.stringify(result))
}
}Durable Objects also have state.waitUntil() — same pattern.
In a Queue consumer
export default {
async queue(batch, env, ctx) {
const results = await Promise.all(
batch.messages.map(msg => processMessage(msg.body))
)
const succeeded = results.filter(r => r.ok).length
const failed = results.filter(r => !r.ok).length
ctx.waitUntil(
fyi("Queue batch processed", {
body: `${succeeded} succeeded · ${failed} failed`,
queue: batch.queue,
count: batch.messages.length
})
)
}
}Alternatively: just await
If you don’t need the response before the notification, await works fine:
export default {
async fetch(req, env, ctx) {
const result = await handleRequest(req, env)
await fyi("Handled", { body: result.id }) // ~100ms
return new Response(JSON.stringify(result))
}
}fyi() never throws, completes in one roundtrip to the nearest edge, and can’t fail your response. The latency cost is minimal for non-latency-sensitive paths.
Related: Push notifications from serverless functions · Push notifications from Next.js · Cron job notifications · What is trigger.fyi?