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: declaring the timeout in the right place (the Func source, not the caller), and using native Fetch plus SSE for bounded incremental output.

Timeout configuration

How long a Func may run is a property of the Func, not of the request that triggered it, so it is declared in the source:

// /backend/func/image.ts
export const config = { timeoutMs: 120000 }

export async function generate(input, ctx) {
  const r = await fetch('https://api.example.com/v1/images', { ... })
  return { url: (await r.json()).url }
}

With a declaration in place the caller no longer has to think about timeouts:

const result = await invoke('image.generate', input)
SourceRule
config.timeoutMs in the FuncWins. When present, the caller's value is ignored
The caller's timeoutMsApplies only when the Func declares nothing, and still works as before, so existing code is unaffected
Neither5 seconds

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

LimitValue
Maximum execution time300 seconds; a larger config.timeoutMs is capped to it
CPU timeAbout 10 seconds. This counts time your code is computing; waiting on fetch, the database or SSE events does not count, so a 2-minute image generation is fine while a tight loop is stopped after 10 seconds
Module top level2 seconds. Keep it to definitions and constants; do real work inside a method
Func result sizeAbout 1 MiB; exceeding it fails the call, the first wall you hit when returning a whole table
fetch response bodyAbout 10 MiB; streaming reads count toward the same total
Func code sizeAbout 256 KiB

Diagnosing context deadline exceeded

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

  1. Check whether the Func has export const config = { timeoutMs }. This is almost always where the problem is: without it you are still on the 5-second default.
  2. Retry the same input and model with a larger declared value. 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, set the declaration to a sensible size 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.

If the error mentions CPU instead (it says raising timeout_ms will not help), that is a different problem: your code spent too long computing. Move the work to an external service or change the algorithm. A larger timeout does nothing for it.

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, and 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, because 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