BackendTimeouts and streaming responses

Timeouts and streaming responses

invoke defaults to 5 seconds, execution caps at 300, and results cap near 1 MiB — which limit a long task actually hits, and the order in which to diagnose context deadline exceeded. Includes the complete ctx.sse.send plus native Fetch parsing loop, and where streaming stops being the answer.

An ordinary invoke() waits for one complete JSON result and times out after 5 seconds by default. Model generation, slow third-party APIs, and anything that should appear while it is being produced all run into that line. This page covers two things: putting the timeout in the right place, and using native Fetch plus SSE for bounded incremental output.

Timeout configuration

invoke defaults to 5 seconds; the Func runner's default maximum execution time is 300 seconds. A bounded model generation or upstream request may set a longer caller timeout. This does not enable detached background work.

const result = await invoke('image.generate', input, {
  timeoutMs: 120000,
})

Several hard limits apply at the same time and do not move when you raise the timeout:

LimitValue
invoke default timeout5 seconds, overridden by the caller's timeoutMs
Maximum execution time300 seconds
Func result sizeAbout 1 MiB; exceeding it fails the call — the first wall you hit when returning a whole table
Func code sizeAbout 256 KiB

Diagnosing context deadline exceeded

For context deadline exceeded or context timeout, do these three things in order:

  1. Inspect the actual invoke(..., { timeoutMs }) value in the page. This is almost always where the problem is — the caller is still on the 5-second default.
  2. Retry the same input and model with a larger timeout. creght func run --timeout_ms / run_func.timeout_ms affects that self-test only: passing there does not prove the production path passes, and failing there does not prove a platform hard limit.
  3. If the larger value succeeds, raise the production caller's timeoutMs and verify the complete path in the real page.

Do not treat shorter output, a lower max_tokens, a different model or provider, a new table, or a fake background job as the timeout fix. Each of those changes the feature, when the actual problem is one number set too low.

Native Fetch + SSE streaming

For bounded incremental output, send events with ctx.sse.send and read them in the browser with native fetch and ReadableStream. No invokeStream wrapper is required.

// /backend/func/writer.ts
export async function main(input, ctx) {
  ctx.sse.send('token', { text: 'Hello' })
  ctx.sse.send('token', { text: ' world' })
  return { ok: true }
}

The browser parses SSE frames itself. The key point is that reader.read() yields arbitrary byte chunks — a chunk is not guaranteed to be one event:

const response = await fetch('/func/writer?stream=1&timeout_ms=120000', {
  method: 'POST',
  headers: {
    Accept: 'text/event-stream',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(input),
})
if (!response.ok || !response.body) throw new Error('Func stream failed')

const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })

  const frames = buffer.split(/\r?\n\r?\n/)
  buffer = frames.pop() || ''
  for (const frame of frames) {
    const event = frame.match(/^event:\s*(.+)$/m)?.[1]
    const data = frame.match(/^data:\s*(.+)$/m)?.[1]
    if (event === 'token' && data) {
      output += JSON.parse(data).text
    }
  }
}
  • You must buffer across reads and split frames only on a blank line, keeping the trailing partial frame for the next chunk.
  • Streaming is selected by ?stream=1&timeout_ms=... on the URL, not by an invoke() option.
  • The platform sends the final done or error event; handle both.
  • Cookies cannot change after the first event — headers are already committed. Anything that sets a session or cookie must happen before the first send.
  • The call timeout still applies. Streaming is not an unbounded stream.

Streaming is not a background job

SSE lets the user see progress sooner, but the output still belongs to one request. Func cannot continue after the request ends: there is no setTimeout/setInterval, no job queue, and no "return now and finish later".

If the work genuinely exceeds 300 seconds, split it into steps the user can see: each step is one bounded call, intermediate state lives in a JSON table, and the page drives the next step — instead of leaving one call hanging.

Acceptance checklist

  • Long-running calls set a timeoutMs matching the real duration in the production page, verified on the real path.
  • Results carry no bulk payload and stay well below the 1 MiB limit.
  • SSE parsing buffers across reads, splits on blank lines, and handles done and error.
  • Any cookie-setting logic completes before the first SSE event.
  • No timers, polling, or fake background jobs are used to work around a timeout.

Render diagnostics