HTTP, WebSocket RPC, snapshots, and resume
Typed contracts span HTTP and Effect RPC, while snapshot, bounded catch-up, buffered live delivery, and client watermarks repair connection races.
What this chapter resolves
- Separate domain schemas, RPC definitions, transport serialization, and handlers.
- Simulate the attach-before-snapshot resume algorithm and its gap cutoff.
- Explain which layer owns coalescing, deduplication, and reconnection.
The server is active, but a client still needs two different kinds of truth: authoritative state it can load now and a live stream that will not miss what changes while that load is happening. T3 Code splits that job across typed HTTP endpoints, typed Effect RPC methods, and a deliberately ordered snapshot/catch-up/live protocol.
Four layers, one contract boundary
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Contracts contain domain snapshot and event schemas. A typed environment HTTP API and typed Effect RPC groups refer to those schemas. Server HTTP and WebSocket handlers implement the operations. The WebSocket route authenticates a session and installs Effect RPC's JSON protocol. The client creates one protocol session and replaces subscriptions when that session changes.
packages/contracts/src/orchestration.ts:501–635 ↗packages/contracts/src/orchestration.ts:1680–1713 ↗packages/contracts/src/rpc.ts:885–948 ↗packages/contracts/src/environmentHttp.ts:486–531 ↗apps/server/src/ws.ts:2398–2468 ↗packages/client-runtime/src/rpc/session.ts:68–145 ↗packages/client-runtime/src/rpc/client.ts:178–280 ↗The schema map does not specify a hand-written WebSocket frame such as
{type, requestId, payload}. Effect RPC owns that protocol envelope and the route
provides JSON serialization. This book therefore documents typed operations and
observable stream items, not a fabricated wire format.
The two transports share decoded command schemas, but not every policy step. HTTP normalizes and dispatches directly. The WebSocket path additionally owns startup queueing, bootstrap compensation, origin attribution, and selected archive/settle follow-ups. “Same contract type” therefore does not mean interchangeable handler semantics.
Capabilities make synchronization evolvable
The first RPC session caches server.getConfig. Besides environment, providers,
settings, and observability data, the response advertises whether the connected
server supports shell and thread completion markers and thread snapshot pagination.
Clients gate newer behavior on those flags rather than assuming version equality.
One WebSocket session attempt explicitly disables internal transport retries. A
higher connection supervisor owns replacement sessions, backoff, lifecycle wakeups,
and target changes; subscribeDynamic switches each durable subscription to the
current session. Keeping retry ownership outside the raw protocol attempt prevents
two independent retry loops from fighting each other.
Attach live first
The central race is simple: if the server reads a snapshot and only then subscribes to live events, an event committed between those operations appears in neither result. T3 Code reverses the first two actions: subscribe into a scope-bound buffer, then inspect authoritative state.
[ORCHESTRATION_WS_METHODS.subscribeThread]: (input) =>
observeRpcStreamEffect(
ORCHESTRATION_WS_METHODS.subscribeThread,
Effect.gen(function* () {
const isThisThreadDetailEvent = (event: OrchestrationEvent) =>
event.aggregateKind === "thread" &&
event.aggregateId === input.threadId &&
isThreadDetailEvent(event);
const liveStream = orchestrationEngine.streamDomainEvents.pipe(
Stream.filter(isThisThreadDetailEvent),
Stream.map((event) => ({
kind: "event" as const,
event: projectActivityEvent(event),
})),
);
// Attach live delivery before reading either replay or snapshot state.
// Otherwise an event published while the snapshot is loading is lost.
const liveBuffer = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
yield* Effect.forkScoped(
liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))),
);
const bufferedLiveStream = Stream.fromQueue(liveBuffer);
// When the client already loaded the snapshot over HTTP it passes
// that snapshot's sequence, and we resume the live subscription by
// replaying persisted events after it instead of re-sending the
// (potentially multi-KB) snapshot frame over the socket.
//
// The live PubSub subscription must be attached *before* draining
// the catch-up replay, otherwise events published during the replay
// window are dropped (they are past the persisted tail the replay
// read, but the live stream is not yet subscribed). So fork the
// live stream into a buffer bound to this stream's scope, then emit
// catch-up followed by the buffered/ongoing live events. Overlapping
// events are deduped by sequence on the client.
//
// The replay is bounded to the projection head captured below. The
// catch-up range is normally tiny (a fresh HTTP snapshot sequence),
// but a stale cached cursor can sit hundreds of thousands of global
// events behind — replaying that decodes every intervening event
// (including every other thread's tool payloads) only to discard
// almost all of them, which has OOM-killed servers on large
// databases. A truncated replay would silently drop this thread's
// events, so past the gap cap we reset the client with a fresh
// thread snapshot instead, exactly like subscribeShell above.
if (input.afterSequence !== undefined) {
const afterSequence = input.afterSequence;
const headSequence = yield* orchestrationEngine.latestSequence;
const replayGap = headSequence - afterSequence;
if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) {
const catchUpStream = orchestrationEngine
.readEvents(afterSequence, replayGap)
.pipe(
Stream.filter(isThisThreadDetailEvent),
Stream.map((event) => ({
kind: "event" as const,
event: projectActivityEvent(event),
})),
Stream.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to replay thread ${input.threadId} events`,
cause,
}),
),
);
const afterCatchUp =
input.requestCompletionMarker === true
? Stream.concat(
Stream.fromEffect(
Queue.offer(liveBuffer, { kind: "synchronized" as const }),
).pipe(Stream.drain),
bufferedLiveStream,
)
: bufferedLiveStream;
return Stream.concat(catchUpStream, afterCatchUp);
}
// Gap too large (or cursor ahead of authoritative state): fall
// through to the snapshot path so the client converges from a
// fresh thread detail instead of an unbounded replay.
}
const snapshot = yield* projectionSnapshotQuery
.getThreadDetailSnapshot(
input.threadId,
// Windowing the fallback snapshot is opt-in per subscription:
// clients that don't send turnLimit (including all
// pre-pagination clients) get the full thread, since they
// have no way to load older pages.
input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit },
)
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to load thread ${input.threadId}`,
cause,
}),
),
);
if (Option.isNone(snapshot)) {
return yield* new OrchestrationGetSnapshotError({
message: `Thread ${input.threadId} was not found`,
cause: input.threadId,
});
}
const afterSnapshot =
input.requestCompletionMarker === true
? Stream.concat(
Stream.fromEffect(
Queue.offer(liveBuffer, { kind: "synchronized" as const }),
).pipe(Stream.drain),
bufferedLiveStream,
)
: bufferedLiveStream;
return Stream.concat(
Stream.make({
kind: "snapshot" as const,
snapshot: projectThreadDetailSnapshot(snapshot.value),
}),
afterSnapshot,
);
}),
{ "rpc.aggregate": "orchestration" },Interleave snapshot, catch-up, and live events
Move through the protocol once. The buffer is attached before the database read so events committed during synchronization have somewhere to wait.
Step 1 of 6: Attach
Subscribe live into a scope-bound buffer
The shell or selected thread stream is attached before any snapshot query or persisted replay. New commits can now queue while synchronization reads run.
- 1. Attach · Subscribe live into a scope-bound buffer (Transport boundary)
The shell or selected thread stream is attached before any snapshot query or persisted replay. New commits can now queue while synchronization reads run.
apps/server/src/ws.ts:1270–1375↗apps/server/src/ws.ts:1394–1521↗packages/contracts/src/orchestration.ts:1506–1519↗ - 2. Capture head · Freeze the replay boundary (Durable state)
When an afterSequence cursor exists, the handler reads the latest global orchestration sequence and computes head − cursor. Replay is bounded to that captured head rather than chasing a moving tail.
apps/server/src/ws.ts:1270–1375↗apps/server/src/ws.ts:1394–1521↗ - 3. Choose base · Replay a bounded gap or replace with a snapshot (Runtime work)
A cursor from 0 through 1,000 events behind replays persisted events. A cursor ahead of the head, more than 1,000 behind, or absent takes the fresh snapshot path.
apps/server/src/ws.ts:308–315↗apps/server/src/ws.ts:1270–1375↗apps/server/src/ws.ts:1394–1521↗apps/server/src/orchestration/http.ts:22–108↗ - 4. Catch up · Emit state through the captured head (Runtime work)
Shell catch-up coalesces projection refetches by aggregate. Thread catch-up scans the global range but emits only matching thread-detail events.
apps/server/src/ws.ts:644–819↗apps/server/src/ws.ts:1270–1375↗ - 5. Synchronize · Place the completion marker behind buffered work (Transport boundary)
When negotiated, synchronized is offered into the same queue as live input. It cannot overtake an event waiting in the shell coalescing window or thread buffer.
apps/server/src/ws.ts:644–819↗apps/server/src/ws.ts:1077–1116↗ - 6. Drain live · Continue from the hot stream (Client state)
Buffered and future live events flow after the base state. Client reducers drop any event whose sequence is not greater than their watermark, removing overlap between replay and live delivery.
packages/client-runtime/src/state/shellReducer.ts:1–45↗packages/client-runtime/src/state/threads.ts:316–365↗packages/client-runtime/src/rpc/client.ts:178–280↗
The cursor decision is intentionally bounded
Choose a resume cursor
Compare the server's actual decision and the client convergence consequence.
Scenario 1 of 4: No cursor
Send a fresh snapshot, then buffered live input
A client without usable local data has no sequence to resume from, so the stream establishes a new authoritative base.
- Thread snapshots may be windowed only when pagination was negotiated.
- The snapshot carries its sequence watermark.
- A requested completion marker follows all buffered work that belongs before it.
- No cursor · Send a fresh snapshot, then buffered live input (Expected path)
A client without usable local data has no sequence to resume from, so the stream establishes a new authoritative base.
- Thread snapshots may be windowed only when pagination was negotiated.
- The snapshot carries its sequence watermark.
- A requested completion marker follows all buffered work that belongs before it.
packages/contracts/src/orchestration.ts:501–635↗apps/server/src/ws.ts:1270–1375↗apps/server/src/ws.ts:1077–1116↗ - Gap ≤ 1,000 · Replay persisted events through the captured head (Expected path)
A non-negative gap at or below the cap avoids replacing already loaded state.
- afterSequence is the global orchestration sequence, not a thread-local counter.
- Unrelated aggregate events count toward the thread replay gap.
- Overlapping live events are harmless only because the client deduplicates by sequence.
apps/server/src/ws.ts:308–315↗apps/server/src/ws.ts:1270–1375↗apps/server/src/ws.ts:1394–1521↗packages/contracts/src/orchestration.ts:1506–1519↗ - Gap > 1,000 · Reset from a fresh snapshot (Caveat)
The cap avoids decoding an unbounded global range only to discard nearly all events for one thread.
- A truncated replay would silently omit relevant thread events, so truncation is not used.
- turnLimit affects the fallback snapshot window, not the replay count.
- The fresh snapshot replaces loaded thread history on the client.
apps/server/src/ws.ts:308–315↗apps/server/src/ws.ts:1394–1521↗packages/contracts/src/orchestration.ts:501–635↗packages/client-runtime/src/state/threads.ts:316–365↗ - Cursor ahead · Treat the cursor as invalid and reset (Failure path)
A negative gap means the client watermark is ahead of this server's authoritative event head, such as after switching or restoring an environment.
- The server sends a snapshot rather than accepting impossible history.
- Client snapshot application replaces the sequence watermark and loaded thread history.
- The new snapshot becomes the current subscription baseline.
apps/server/src/ws.ts:1270–1375↗apps/server/src/ws.ts:1394–1521↗packages/client-runtime/src/state/threads.ts:316–365↗
Shell and thread streams optimize different projections
| Concern | Shell stream | Thread-detail stream |
|---|---|---|
| Projection | project/thread summaries for navigation | one thread’s messages, activities, turns, plans, checkpoints, and session detail |
| Live optimization | 50 ms / 512-input window; last event per aggregate triggers a current-shell refetch | filtered detail events stream without shell coalescing |
| Replay cutoff | global gap 0…1,000 | global gap 0…1,000, then filter to the requested thread |
| Fallback | fresh shell snapshot | fresh thread snapshot, optionally windowed by negotiated turnLimit |
The threadSequence attached to an older-page snapshot is a thread-scoped merge
watermark for pagination. It is not the afterSequence subscription cursor. The
client can park a page read ahead of its live state until that watermark arrives,
while its subscription continues to advance on the global sequence.