How to Fix: waitUntil not working for Remix.run apps ?
waitUntil appears to do nothing in some Remix.run deployments because the request is not actually running inside the runtime that owns the background lifecycle. In this issue, the Remix loader returns successfully, but the deferred work scheduled with waitUntil() never completes the way developers expect on Vercel.
Understanding the Root Cause
The bug is usually not that waitUntil is broken by itself. The real problem is that Remix loaders and the Vercel runtime integration do not always expose the same execution model as frameworks that natively wire background tasks into the platform request context.
On Vercel, waitUntil() is designed to extend the life of a request so that asynchronous work can continue after the response is sent. That only works when all of the following are true:
- The code runs in a runtime that supports request-scoped background execution.
- The current framework adapter correctly exposes the platform context.
- The async task is attached to the same lifecycle as the incoming request.
- The task does not depend on Node behavior that is torn down immediately after the response finishes.
In affected Remix setups, the loader may execute through an adapter path where Vercel-specific context is missing or incomplete. As a result, calling context.waitUntil(…) either is not available, is a no-op, or does not keep the process alive long enough for the promise to settle.
This is especially confusing because the response still returns correctly. From the outside, it looks like the loader worked, but the post-response side effect, such as logging, database writes, analytics, webhook delivery, or queue publishing, silently never finishes.
Another important detail is the difference between:
- Returning a response after awaiting work, which blocks the user until the task completes.
- Scheduling work with waitUntil, which should let the response return immediately while the platform continues processing the promise.
If the Remix adapter does not preserve that request lifecycle on Vercel, then waitUntil cannot guarantee background completion.
Step-by-Step Solution
The reliable fix is to make sure your Remix app is using a Vercel-compatible execution path that actually supports waitUntil. If that is not available in your current adapter/runtime combination, move the background task to a mechanism that Vercel supports consistently, such as a dedicated function, queue, cron-triggered workflow, or an explicitly awaited operation.
1. Check whether the platform context is really available
In your Remix loader or action, inspect the incoming context. If waitUntil is undefined, you are not in the expected runtime path.
export async function loader({ context }) {
console.log("context keys:", Object.keys(context || {}));
console.log("waitUntil exists:", typeof context?.waitUntil === "function");
return new Response("ok");
}
If this logs false, the issue is not your promise logic. The runtime integration is the problem.
2. Use waitUntil only when the runtime supports it
If your deployment environment provides context.waitUntil, attach the promise without awaiting it.
export async function loader({ context }) {
context.waitUntil(
fetch("https://example.com/api/audit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ event: "loader_hit" })
}).catch((error) => {
console.error("background task failed", error);
})
);
return new Response("response sent immediately");
}
Two key rules here:
- Do not wrap the work in a fire-and-forget promise without waitUntil.
- Do catch errors inside the background task so failures are observable.
3. If waitUntil is unavailable, use a fallback pattern
For Remix apps where Vercel does not expose waitUntil correctly, use one of these fallback approaches.
Option A: Await the task directly
This is the simplest and most correct approach when the work must succeed before the request finishes.
export async function loader() {
await fetch("https://example.com/api/audit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ event: "loader_hit" })
});
return new Response("done");
}
Tradeoff: higher latency for the end user.
Option B: Push the work to an external queue
This is usually the best production-grade solution for analytics, emails, webhooks, cache invalidation, and non-critical side effects.
export async function loader() {
await fetch("https://example.com/api/jobs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "audit_event", payload: { event: "loader_hit" } })
});
return new Response("queued");
}
Your queue worker then processes the job independently of the user request.
Option C: Move the logic into a platform-native endpoint
If part of your app can run in a Vercel-native handler that fully supports waitUntil, send the background work there instead of trying to do it inside the Remix loader.
export async function loader() {
await fetch("/api/background-trigger", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ event: "loader_hit" })
});
return new Response("triggered");
}
Then in the platform-native handler:
export default async function handler(req, res) {
res.status(200).json({ ok: true });
// Use the platform-supported background mechanism here if available.
}
4. Avoid fake background execution patterns
Many developers try this pattern:
export async function loader() {
fetch("https://example.com/api/audit", { method: "POST" });
return new Response("ok");
}
This is not reliable. Once the response is returned, the runtime may terminate before the fetch completes. Without waitUntil or a durable queue, the task can be lost.
5. Verify with observable side effects
Do not test this only with console.log. Logs can be buffered or misleading. Instead, verify with something persistent:
- a database insert
- a webhook receiver
- an external audit endpoint
- a queue message count
This makes it clear whether the background task actually ran to completion.
Common Edge Cases
- Using Node-specific APIs in an Edge-style runtime: Even if waitUntil exists, the task can fail because the code uses unsupported modules such as filesystem or certain networking libraries.
- Unhandled promise rejection: If the promise passed to waitUntil rejects and you do not catch it, the platform may report a runtime error while the user still receives a successful response.
- Assuming local dev matches production: Remix development mode often behaves differently from the deployed Vercel runtime. Always test on a preview deployment.
- Adapter mismatch: A Remix app can build and serve correctly while still lacking the exact platform context needed for waitUntil.
- Long-running tasks: Even background work still has platform limits. If the job is heavy, use a queue or worker instead of relying on request-scoped background execution.
- Internal fetch to relative URLs: Some server-side runtimes need absolute URLs or special handling for internal calls, especially when deployed across multiple execution layers.
FAQ
Why does waitUntil work in other frameworks but not in my Remix app?
Because framework adapters differ. Some frameworks integrate directly with the Vercel request lifecycle and expose waitUntil correctly. In some Remix deployments, that lifecycle is not wired the same way, so the background promise is not preserved after the response ends.
Can I use an unawaited promise instead of waitUntil?
No. An unawaited promise is not durable in serverless or edge environments. The runtime can stop immediately after the response is returned, which means the task may never finish.
What is the safest fix for production workloads?
The safest fix is to use a durable queue or explicitly await the operation if it must complete during the request. Use waitUntil only when you have confirmed that your Remix runtime on Vercel truly supports it.
In short, the issue is caused by a mismatch between Remix runtime integration and Vercel’s request-lifecycle background execution model. If waitUntil is missing or ineffective in your loader, treat that as a platform integration limitation, not just an application bug, and switch to a verified runtime path or a durable background-processing strategy.