Skip to content

Keeping a program alive

Nothing survives an execution, per the persistence contract. retainHandles is the one negotiated exception: ask for it, and a run whose answer is a request handler keeps its isolate alive for your host to invoke later.

Some programs are not a computation. They are a service: the model writes a request handler, and the host would rather route requests to it than run the program again for each one. Running it again repeats the setup and discards what the program built before it returned that handler: the catalogue it loaded, the index it computed, the client it configured. Ask for a handle when the setup costs more than the request that follows it, and when you have traffic to route and not one question to answer. But for a program that is a computation, retainHandles neither costs nor grants anything, so passing it defensively is harmless and pointless.

const outcome = await executor.execute({
code: `async () => {
const catalogue = await store.listProducts()
let hits = 0
return {
fetch: async (request) => {
hits += 1
const id = new URL(request.url).searchParams.get('id')
return Response.json({ hits, item: catalogue.find((item) => item.id === id) ?? null })
},
}
}`,
providers: [{ name: 'store', tools: ['listProducts'] }],
dispatch,
retainHandles: true,
})
{
result: undefined,
logs: [],
executionId: 'd6201803-2236-4c65-be08-ff9eaca95a75',
handles: [ 'fetch' ]
}

A run answers with a handler when its result is a function, or an object carrying a fetch method. That object is the shape a Workers entrypoint exports. Both are granted the same way, and a bare async (request) => new Response('…') is retained exactly like the object above.

No result crosses the envelope when handles are granted, since a function cannot cross the boundary as a value. What crosses is the identifier and the names invokeHandle will accept, a list whose only member so far is fetch. But a result of any other shape comes back as usual and retains no isolate. Its absent executionId says so in-band, so a host branches on what it got and not on what it asked for. Logs of the run itself come back with the grant, the way they do for any other run. Console output during later invocations is discarded.

The handler keeps whatever the arrow closed over. Because the entry contract is an expression, a program cannot write statements before the arrow and has no module scope of its own to build in. State lives in the arrow’s body, or in a module the program imported:

// state in the arrow's body
code: `async () => { let served = 0; return { fetch: async () => Response.json({ served: ++served }) } }`
// invocation 1 -> {"served":1}
// invocation 2 -> {"served":2}
// state in an imported module
code: `import { bump } from './counter.js'
async () => ({ fetch: async () => Response.json({ served: bump() }) })`
modules: { './counter.js': `let served = 0
export const bump = () => (served += 1)` }
// invocation 1 -> {"served":1}
// invocation 2 -> {"served":2}

served survives between invocations because the isolate does. Timers stay alive with it, and a counter driven by a setInterval keeps climbing while nobody calls. Two bounds hold what those timers do there, the memory limit and the idle bound, and an isolate they exhaust reports the memory text on the next invocation.

const answer = await executor.invokeHandle(outcome.executionId, 'fetch', {
method: 'GET',
url: 'https://catalogue.internal/item?id=42',
headers: [],
body: null,
})
{
status: 200,
statusText: '',
headers: [ [ 'content-type', 'application/json' ] ],
body: ArrayBuffer // '{"hits":1,"item":{"id":"42","name":"hat"}}'
}

Both the request and the answer take the same wire shapes an outbound policy uses, so a host that already translates between its own framework and those types reuses the translation. A body travels as an ArrayBuffer in both directions:

await executor.invokeHandle(outcome.executionId, 'fetch', {
method: 'POST',
url: 'https://host/items',
headers: [['content-type', 'application/json']],
body: new TextEncoder().encode('{"id":7}').buffer,
})
// the handler reads request.method 'POST', the content type, and await request.json() as { id: 7 }

Inside the isolate the handler receives a real Request and has to answer a real Response. The host validates that answer again on its own side, because a program can redefine what Response means inside its own isolate. Two texts exist for a wrong answer:

The fetch handle answered with a value that is not a Response.
The fetch handle answered outside the response wire shape.

Names outside the granted list are refused without the handler being touched, and the handles keep serving:

Execution "<id>" has no handle named "scheduled". The granted handles are: fetch.

Throwing from the handler rejects that one invocation with the handler’s own message and leaves the handles serving, so a handler that fails on one request still answers the next. Invocations of one execution are served one at a time, in the order they arrive.

The run that granted the handles has ended. Its dispatch ended with it. A tool call from inside a handler meets the pinned completion text and never reaches your data:

code: `async () => ({ fetch: async () => { try { await store.listProducts() } catch (failure) { return new Response(failure.message) } } })`
// the invocation answers: Execution has already completed.

Setup is the run itself. Load whatever the handler needs from your side before the run returns. Outbound stays whatever the request chose, and a handler can still fetch if you left it open. But one exception applies over the wire. A caller-supplied outbound function crosses as a stub of the connection that carried the execute, so a handler’s fetch through it fails once that connection rotates or closes.

The isolate stays alive until something ends it, and four things do. Every one of them leaves the same fault behind. Closing it yourself is the normal path:

await executor.closeHandles(outcome.executionId)

Leave one idle and the idle bound closes it, and the worker returns to rotation. A daemon leaves that bound where the pool sets it, and the default is in the published API. Treat it as a safety net and not as the mechanism. A run bound ends the whole session and not merely the call. Each invocation runs under the execution’s own timeout and memory limit, with a fresh bound per invocation. An invocation that outlives its bound reports the declared text and closes the handles. Stopping the run means disposing the isolate:

Execution timed out at its 500 ms bound. Do less work per run and return the part that is already finished.

Either the worker dying or the pool shutting down ends it too, and both publish the close, so a host holding resources for that execution can release them. Afterwards every call answers the same fault, whichever of the four happened:

ExecutorError | HANDLES_CLOSED | retryable false
Execution "<id>" holds no handles: they were closed, expired at the idle bound, or never granted.
Execute again with retainHandles to get a fresh executionId.

That same fault answers an executionId that never existed, so a host cannot tell a closed execution from an invented one, and neither can a caller probing for identifiers.

Whoever holds the executionId can invoke a handle. It is minted on the host from crypto.randomUUID, and it is a bearer capability on purpose. Every caller a daemon admits belongs to one trust domain, and identity on the handshake is attribution and not scope. So a deployment whose callers distrust each other runs a daemon for each, or authorizes at the gateway in front. The threat model states the same, among the controls the operator owns. Over the wire the handles are keyed on the serving executor and not on the connection, so a fresh connection may keep invoking them. Behind a load balancer that means the caller has to reach the same daemon. Each retained isolate lives on one machine.

Retained executions hold their worker’s slot for as long as they live, so a deployment that grants handles counts them in its worker budget instead of in its throughput. Eight retained executions on a pool of eight workers leave no worker free for a computation, and the callers meet the queue bound. Close them as soon as you are done, in a finally if the routing has an end. If most of your programs are computations, retain none of them and let each isolate go. The pool chapter says how a retained execution occupies a worker, and what the pool publishes when one closes.

Handles are the last capability the guides hand out. Moving a host onto this executor is for a host already running @cloudflare/codemode, and it takes up the one default that reverses when you swap. The executor protocol is the contract the whole section rests on.