Summary
A persistent stub created via ctx.restore(params) in a Durable Object, stored in DO
storage, then returned out to a different Worker and invoked there, does not make progress:
the invocation sits at 0 CPU until the runtime cancels it. The target's [restore](params)
method is never invoked. The same code path completes normally when all Workers run in a
single process (local dev / one workerd instance).
Environment
wrangler ^4.103.0
compatibility_date: 2026-02-02
compatibility_flags: ["experimental", "allow_irrevocable_stub_storage"]
- Deployment topology: multiple separate, service-bound Workers, plus Durable Objects hosted
in one of them. The behavior was observed only in a real multi-Worker deployment (separate
processes/isolates), not in a single-process runtime.
Setup under which it was observed
Three execution contexts are involved:
- Caller Worker — a separate Worker holding a service-binding
Fetcher to a
WorkerEntrypoint in another Worker.
- Host Worker — hosts both that
WorkerEntrypoint and the Durable Object namespace.
- The DO — a Durable Object in the Host Worker that owns the persistent stub. The stub is
created inside the DO via ctx.restore(params), implements [restore](params), and is stored
in the DO's storage under allow_irrevocable_stub_storage.
The persistent stub is delivered to the Caller Worker by value across two hops, then invoked there:
DO ──(returns persistent stub)──▶ WorkerEntrypoint (Host) ──(returns it)──▶ Caller Worker
Caller Worker: stub.someMethod(args) // <-- hangs
Minimal reproduction (sketch)
Two Workers, service-bound. host exposes a DO and a WorkerEntrypoint; caller invokes
through the entrypoint.
// ---- host Worker ----
import { DurableObject, WorkerEntrypoint, RpcTarget, RpcStub, restore } from "cloudflare:workers";
class Greeter extends RpcTarget {
constructor(private greeting: string) { super(); }
greet(name: string) { return `${this.greeting}, ${name}!`; }
}
export class HostDO extends DurableObject {
[restore](params: { greeting: string }) {
return new Greeter(params.greeting); // <-- rebuilds the live target
}
async makePersistentStub(): Promise<RpcStub<Greeter>> {
const stub = await this.ctx.restore({ greeting: "Hello" }); // persistent stub
// (in the real system this is stored under allow_irrevocable_stub_storage and read back later)
return stub; // returned by value to the caller
}
}
export class HostEntry extends WorkerEntrypoint {
async getStub(): Promise<RpcStub<Greeter>> {
const ns = this.ctx.exports.HostDO;
const doStub = ns.get(ns.idFromString(/* fixed id */));
return await doStub.makePersistentStub(); // re-export DO's persistent stub out of host
}
}
// ---- caller Worker (separate Worker, service-bound to host) ----
export default {
async fetch(req, env) {
const stub = await env.HOST.getStub(); // persistent stub, two hops out
const result = await stub.greet("world"); // <-- expected "Hello, world!"; observed: hangs at 0 CPU
return new Response(result);
}
};
Expected: "Hello, world!". Observed in a real two-Worker deployment: stub.greet("world")
hangs at 0 CPU until canceled; HostDO[restore] is never called.
Expected behavior
Invoking a method on the stub from the Caller Worker routes back to the Host Worker / DO, drives
the stub's [restore](params) to rebuild the live target, invokes the method, and returns the
result — as it does when everything runs in one process.
Observed behavior
- The Caller Worker's method invocation hangs. Measured wall-clock durations of ~100–190s at
0 CPU before the runtime cancels the request (in our case via a Durable Object alarm's
wall-clock limit; the alarm is then killed and its buffered logs discarded).
- The DO's
[restore] and the target method never run: no logs from inside [restore] or the
target ever appear, and the DO invocation is ultimately canceled.
- The Caller Worker receives a sanitized
"internal error; reference = <id>" after cancellation.
Additional observations
- Single-process works. With all Workers running in one process (local dev), the same code —
the stub is still passed to the other Worker and invoked there — completes normally.
- Not a pipelining / un-awaited-promise artifact. We changed the intermediate
WorkerEntrypoint hop to await the DO RPC, so the Caller Worker received a resolved, concrete
stub rather than a pipelined promise. The invocation still hung.
- Ordinary cross-Worker RPC is unaffected. Calls on the service-binding
Fetcher, and calls
on ordinary (non-persistent, live) RpcTarget objects returned across the same boundary,
complete normally in the same deployment. Only invoking a [restore]()-backed stub that was
re-exported to another Worker exhibits the hang.
Summary
A persistent stub created via
ctx.restore(params)in a Durable Object, stored in DOstorage, then returned out to a different Worker and invoked there, does not make progress:
the invocation sits at 0 CPU until the runtime cancels it. The target's
[restore](params)method is never invoked. The same code path completes normally when all Workers run in a
single process (local dev / one
workerdinstance).Environment
wrangler^4.103.0compatibility_date:2026-02-02compatibility_flags:["experimental", "allow_irrevocable_stub_storage"]in one of them. The behavior was observed only in a real multi-Worker deployment (separate
processes/isolates), not in a single-process runtime.
Setup under which it was observed
Three execution contexts are involved:
Fetcherto aWorkerEntrypointin another Worker.WorkerEntrypointand the Durable Object namespace.created inside the DO via
ctx.restore(params), implements[restore](params), and is storedin the DO's storage under
allow_irrevocable_stub_storage.The persistent stub is delivered to the Caller Worker by value across two hops, then invoked there:
Minimal reproduction (sketch)
Two Workers, service-bound.
hostexposes a DO and aWorkerEntrypoint;callerinvokesthrough the entrypoint.
Expected:
"Hello, world!". Observed in a real two-Worker deployment:stub.greet("world")hangs at 0 CPU until canceled;
HostDO[restore]is never called.Expected behavior
Invoking a method on the stub from the Caller Worker routes back to the Host Worker / DO, drives
the stub's
[restore](params)to rebuild the live target, invokes the method, and returns theresult — as it does when everything runs in one process.
Observed behavior
0 CPU before the runtime cancels the request (in our case via a Durable Object alarm's
wall-clock limit; the alarm is then killed and its buffered logs discarded).
[restore]and the target method never run: no logs from inside[restore]or thetarget ever appear, and the DO invocation is ultimately canceled.
"internal error; reference = <id>"after cancellation.Additional observations
the stub is still passed to the other Worker and invoked there — completes normally.
WorkerEntrypointhop toawaitthe DO RPC, so the Caller Worker received a resolved, concretestub rather than a pipelined promise. The invocation still hung.
Fetcher, and callson ordinary (non-persistent, live)
RpcTargetobjects returned across the same boundary,complete normally in the same deployment. Only invoking a
[restore]()-backed stub that wasre-exported to another Worker exhibits the hang.