Background Jobs & Long-Running Work
Every request in a PRISM route — both pages and route.ts API handlers — runs inside a V8 isolate with a wall-clock render deadline (BEXT_RENDER_DEADLINE_MS, default 28 s). When the deadline elapses the isolate is terminated, so any single request that needs to do slow work — a multi-step LLM call, building a PDF, a large export, a fan-out batch — will be killed mid-flight.
The fix is not a bigger timeout. It is to keep the request fast and move the slow work off the isolate: accept the request, enqueue a job, return immediately, and let a worker that is not bound by the render deadline do the heavy lifting. The client follows progress over Server-Sent Events.
The deadline applies to API handlers too, not just page renders. A route.ts that awaits a 30 s LLM call will be terminated even though it returns JSON. Routing slow work through a queue or scheduler whose consumer is itself a PRISM route does not help — the consumer hits the same 28 s wall. The work must run in a process outside V8.
The shape of the pattern#
Request ──▶ enqueue job (KV / queue) ──▶ 202 { jobId } (fast, in-isolate)
│
Worker (off-isolate) ▼
drain queue ──▶ do the slow work ──▶ write result + status
│
Client ──▶ SSE /…/stream?jobId=… ──▶ running → completed (live status)
Three pieces: a job record (durable state), a worker that runs outside the isolate, and a status channel back to the UI.
1. Enqueue from the request#
Keep the handler trivial — persist a job and return its id. The app KV store (loopback SDK, app-scoped) is the simplest durable backing; the built-in queue works the same way for higher throughput.
// app/api/reports/route.ts — returns instantly, does no slow work
export async function POST(request: Request): Promise<Response> {
const { websiteId } = await request.json();
const jobId = "job_" + crypto.randomUUID();
// app-scoped KV via the loopback SDK (X-Bext-App-Id)
await kvSet("job:" + jobId, JSON.stringify({ id: jobId, websiteId, status: "pending" }));
await pushPending(jobId); // append to a "jobs:pending" list
return new Response(JSON.stringify({ jobId, status: "pending" }), {
status: 202,
headers: { "content-type": "application/json" },
});
}
KV values are JSON-encoded by the SDK layer, so a value you JSON.stringify round-trips double-encoded. When reading a job back, parse defensively (peel until you have a non-string).
2. Run the work off the isolate#
The worker must run in a real runtime (Bun/Node subprocess), not a V8 isolate. The cleanest driver is a scheduled command task — see the Task Scheduler guide — which spawns a subprocess with a real timeout_secs (default 300, not 28):
# bext.config.toml — drains the queue every minute; no render deadline applies
[[tasks]]
name = "report-drain"
schedule = "* * * * *"
kind = "command"
command = "bun tools/report-runner.ts drain"
timeout_secs = 200
The runner is an ordinary script that shares the same data layer (it can call the loopback SDK for KV/DB exactly like in-isolate code), so it can reuse your generation logic verbatim — and, freed from the 28 s cap, use a larger model or a multi-step pipeline:
// tools/report-runner.ts — runs in Bun, no V8 deadline
for (const jobId of await claimPending()) {
await patch(jobId, { status: "running" });
try {
const result = await generateReport(job.websiteId); // ~45 s is fine here
await patch(jobId, { status: "completed", result });
} catch (e) {
await patch(jobId, { status: "failed", error: String(e) });
}
}
For a recurring batch (e.g. a weekly report run) add a second task with a cron such as 0 9 * * 5 pointing at the same runner. An external system cron + flock works identically if you prefer to keep scheduling outside the app.
3. Stream status back with SSE#
Give the UI a live view with a dedicated streaming route that polls the job record and pushes deltas — the standard SSE pattern. Flush a first byte before any await so the response streams instead of buffering, and self-terminate after a short burst so the EventSource reconnects (keeping streaming-slot usage bounded):
// app/api/reports/stream/route.ts
export async function GET(request: Request): Promise<Response> {
const jobId = new URL(request.url).searchParams.get("jobId") || "";
const enc = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (s: string) => controller.enqueue(enc.encode(s));
send("retry: 2000\n: open\n\n"); // flush before awaiting → forces streaming
for (let i = 0; i < 16; i++) {
const job = await getJob(jobId);
if (job) {
send(`event: status\ndata: ${JSON.stringify(job)}\n\n`);
if (job.status === "completed" || job.status === "failed") break;
}
await new Promise((r) => setTimeout(r, 1500));
}
controller.close();
},
});
return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
}
// client island
const es = new EventSource("/api/reports/stream?jobId=" + jobId);
es.addEventListener("status", (ev) => {
const job = JSON.parse(ev.data);
if (job.status === "completed") { es.close(); location.reload(); }
});
A streaming response pins a V8 slot for its lifetime, so it escapes the buffered 28 s render deadline (it has its own per-iteration limit and a longer slot cap) but you should reserve dedicated capacity with BEXT_V8_POOL_STREAMING_LANE=N if you run many concurrent SSE connections. The short-burst + reconnect approach above keeps each connection well under the slot cap. If you'd rather avoid streaming entirely, a small setInterval poll of a plain JSON status endpoint is a robust substitute.
When you don't need all three#
- Fits in a few seconds? Just do it in the request — no queue needed.
- Slow but the user can wait on the page? A streaming route alone (no queue) can run the work and stream the result, since streaming escapes the buffered deadline.
- Slow and must survive the user navigating away, or runs on a schedule? Use the full queue + off-isolate worker; the SSE/poll is only for live feedback.
Related#
- Task Scheduler — cron/interval/one-shot tasks, including the
commandkind that spawns a subprocess - Scheduled Jobs — the higher-level scheduled-job capability and durable queue
- WebSockets & SSE — server-sent events and streaming responses
- Durable Flows — crash-recoverable multi-step workflows with retries