Fire-and-Forget Trap: Serverless Async Bugs on Vercel
3 production bugs with the same root cause: un-awaited async at Vercel request boundaries. How fire-and-forget silently breaks on serverless and the fix.
The Fire-and-Forget Trap: Why Serverless Async Silently Drops | AI PM Portfolio
The Fire-and-Forget Trap: Why Your Serverless Async Is Silently Dropping in Production
April 11, 2026 · 8 min read · Next.js + Supabase + AI
Fire-and-forget async patterns -- un-awaited fetch(), (async () => {})().catch(() => {}) IIFEs, and .then() callbacks -- work fine on long-running servers but silently break on Vercel and other serverless platforms. The container dies when the HTTP response is sent, killing any pending async work with zero error logs. The fix is straightforward: await inline and set maxDuration. Save workflow engines for later.
Last Updated: 2026-04-11
What happens when Vercel kills your async?
I found 3 production bugs in a single evening. All three had different symptoms -- duplicate emails, a feature that never fired, a notification that never sent. All three had the same root cause: un-awaited async work at Vercel serverless request boundaries.
On a traditional Node.js server, the process stays alive indefinitely. A fetch() you fire without await will eventually complete. On Vercel's serverless model, the container is killed as soon as return NextResponse.json(...) executes. According to Vercel's own documentation, serverless functions have a finite execution window, and any work not completed when the response is sent is terminated -- not queued, not retried, just gone. No error logs. No stack trace. The promise simply ceases to exist.
This is not a Vercel bug. It is the documented behavior of every serverless platform -- AWS Lambda, Google Cloud Functions, Azure Functions. The container lifecycle is tied to the request lifecycle. Fire-and-forget is an anti-pattern in this model. I wrote about serverless architecture tradeoffs previously.
What did the 3 bugs look like?
Here is what we found during a routine end-to-end test on staging. One tester. One session. Three bugs with the same DNA.
| Bug | Symptom | Async Pattern | Impact |
|---|---|---|---|
| #1: Duplicate emails | User received 13 "documents received" emails in 2 minutes | Email log never written on send -- dedup check always passed | 3 users affected (13, 9, and 4 duplicates respectively) |
| #2: Video walkthrough never generated | walkthrough_status stayed NULL for every real client |
Un-awaited (async () => {})().catch(() => {}) IIFE |
Feature silently broken since launch -- only 2 internal test accounts ever saw it work |
| #3: Draft-ready email never sent | CPA uploaded a draft, client was never notified | Un-awaited fetch() to extraction endpoint |
Primary client notification path silently dropped |
Why does fire-and-forget look correct but break silently?
Bug #2 was the most insidious. The extraction route checked every precondition -- document type, extraction status, draft flag -- all passed. Then it wrapped the 45-60 second walkthrough generation pipeline in an un-awaited IIFE:
// BROKEN: Vercel kills this before the UPDATE even fires
if (isDraftDoc && extractionStatus === "done") {
(async () => {
const { data: claimed } = await supabase
.from("tax_returns")
.update({ walkthrough_status: "queued" })
.eq("id", returnId)
.select("id");
if (claimed) {
await generateWalkthroughAssets(returnId); // ~45-60s
}
})().catch(() => {}); // fire-and-forget
}
return NextResponse.json({ ok: true }); // container dies hereThe HTTP 200 returned immediately. Vercel terminated the container. The atomic claim UPDATE never reached the database. The .catch(() => {}) never fired because the promise was not rejected -- it was terminated. Zero error logs in Vercel. Zero entries in Sentry. The feature was silently non-functional for every real client since Sprint 1. Only 2 internal test accounts that had been manually regenerated via the admin UI ever had walkthrough data.
Bug #3 followed the same pattern. The confirm route dispatched extraction via an un-awaited fetch(). The extraction endpoint's post-processing block -- which writes CPA draft numbers and sends the "your draft is ready" email -- never ran because the extraction request itself was dropped before it left the container. I have written about building reliable extraction pipelines before.
How do you fix fire-and-forget on serverless?
The tactical fix is simple: await the work and extend maxDuration. No workflow engine needed.
// FIXED: await inline + maxDuration
export const maxDuration = 300; // 5 minutes -- enough for extraction + generation
if (isDraftDoc && extractionStatus === "done") {
try {
const { data: claimed } = await supabase
.from("tax_returns")
.update({ walkthrough_status: "queued" })
.eq("id", returnId)
.select("id");
if (claimed) {
await generateWalkthroughAssets(returnId); // now runs to completion
}
} catch (err) {
logger.error(`[walkthrough] generation failed: ${err}`);
}
}
return NextResponse.json({ ok: true }); // container stays alive until hereResponse time on the admin confirm button went from approximately 2 seconds to 60-90 seconds. That is a real tradeoff. But this was an admin-only path, not a client-facing interaction. A 60-second spinner for an admin is acceptable. A silently broken feature for every client is not.
When should you use a workflow engine instead of await-inline?
The temptation after fixing these bugs was to migrate everything to Inngest (a serverless workflow engine). We resisted. The rule we adopted: ship await + maxDuration first, migrate to a queue only after the queue has 5 or more successful real-client runs and at least one proven retry. I covered the broader evaluation of workflow engines in a previous post.
| Approach | Await Inline | Workflow Engine (Inngest, Temporal, etc.) |
|---|---|---|
| Implementation time | 30 minutes | 2-3 hours (extract logic into shared library) |
| Rollback complexity | Single git revert | Event routing, handler deregistration |
| Retry on failure | None (manual re-trigger) | Automatic with backoff + DLQ |
| Observability | Vercel logs only | Dashboard with run history |
| Response time impact | +30-90s on admin paths | None (decoupled) |
| Best for | Admin paths, low-volume, immediate fix | Client-facing paths, fan-out, retries needed |
The migration from await-inline to a workflow engine later is approximately 30 minutes of work -- replacing await someFn() with await inngest.send({ name: "..." }) plus a handler file. You lose nothing by starting with await. You risk stacking bugs by migrating to a queue you have not yet proven in production.
The pattern to watch for in code review: Search your codebase for .catch(() => {}), un-awaited fetch() calls, and (async () => {})().catch inside any API route deployed to Vercel. Every instance is a candidate for this same class of silent failure. In our codebase, we found 3 in one evening. Your codebase likely has more.
What is the detection pattern for this class of bug?
These bugs are invisible in logs because the container is killed, not the code. No exception is thrown. No rejection is caught. The detection heuristic that found all 3 of ours: compare expected database state against actual database state after a known trigger.
- Define the expected post-condition -- after uploading a draft,
walkthrough_statusshould bereadywithin 90 seconds. - Query the database -- is the field still NULL? If yes, the async work was dropped.
- Check Vercel logs for the function invocation -- if the log lines for the post-processing block are absent (not errored, absent), the container was killed before reaching that code.
We verified our fixes by creating a fresh test account, uploading 5 documents in a batch, and checking 4 things: exactly 1 email sent (not 13), walkthrough_status flipped to ready, numbers_source set to cpa_draft, and 1 draft-ready email in the outbound log. All 4 passed.
Frequently Asked Questions
Does Next.js after() solve this problem?
No. after() from Next.js is unreliable on Vercel serverless for the same reason -- the function shuts down before the callback runs. The Vercel documentation itself recommends using waitUntil() for background work, but even waitUntil has platform-specific limits. The safest pattern is to await inline or use a dedicated background job system like Inngest or QStash.
How do I find fire-and-forget patterns in my codebase?
Search for these patterns in any file under your API routes directory: .catch(() => {}), .catch(() => undefined), void fetch(, and un-awaited (async () => IIFEs. In TypeScript, the no-floating-promises ESLint rule catches most of these at compile time. We had it disabled for the affected files -- a mistake.
Is this only a Vercel problem?
No. Every serverless platform -- AWS Lambda, Google Cloud Functions, Azure Functions, Cloudflare Workers -- has the same lifecycle constraint. The container or isolate is recycled when the response completes. Fire-and-forget is a long-running server pattern. If you are migrating from Express or Fastify to serverless, audit every un-awaited async call.
What is the performance cost of await-inline?
Response times increase by the duration of the previously-backgrounded work. In our case, admin-facing routes went from 2 seconds to 60-90 seconds. Client-facing routes were unaffected because they were already on separate paths. The key question is: does this path have a human waiting on the response? If it is an admin action or a webhook handler, the latency is acceptable. If it is a user-facing interaction, use a workflow engine instead.
Last Updated: 2026-04-11. Part of a series on building production AI systems with Next.js, Supabase, and serverless infrastructure. 3 bugs discovered and fixed in a single E2E testing session.
Dinesh Challa is an AI Product Manager building production software with Claude Code. Follow him on LinkedIn.