bext.dev
DocsGuides
Guides5 min read
On this page

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.

Warning

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#

code
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.

ts
// 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" },
  });
}
Tip

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):

toml
# 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:

ts
// 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):

ts
// 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" } });
}
js
// 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(); }
});
Note

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.
  • Task Scheduler — cron/interval/one-shot tasks, including the command kind 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
Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.