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)
| Source | Rule |
|---|---|
config.timeoutMs in the Func | Wins. When present, the caller's value is ignored |
The caller's timeoutMs | Applies only when the Func declares nothing, and still works as before, so existing code is unaffected |
| Neither | 5 seconds |
Several hard limits apply at the same time and do not move when you raise the timeout:
| Limit | Value |
|---|---|
| Maximum execution time | 300 seconds; a larger config.timeoutMs is capped to it |
| CPU time | About 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 level | 2 seconds. Keep it to definitions and constants; do real work inside a method |
| Func result size | About 1 MiB; exceeding it fails the call, the first wall you hit when returning a whole table |
fetch response body | About 10 MiB; streaming reads count toward the same total |
| Func code size | About 256 KiB |
Diagnosing context deadline exceeded
For context deadline exceeded or context timeout, do these three things in order:
- 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. - Retry the same input and model with a larger declared value.
creght func run --timeout_ms/run_func.timeout_msaffects that self-test only: passing there does not prove the production path passes, and failing there does not prove a platform hard limit. - 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 aninvoke()option. - The platform sends the final
doneorerrorevent; 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
timeoutMsmatching 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
doneanderror. - Any cookie-setting logic completes before the first SSE event.
- No timers, polling, or fake background jobs are used to work around a timeout.
