Creating and using forked streams. Fork a source stream at a specific offset using Stream-Forked-From and Stream-Fork-Offset headers via DurableStream.create(). Reads transparently stitch inherited and fork data. Covers fork creation, fresh handle pattern, TTL/expiry inheritance, content-type inheritance, and deletion lifecycle. Load when forking, branching, or creating a stream variant from an existing stream.
This skill builds on durable-streams/getting-started. Read it first for setup and offset basics.
Fork creates a new stream that references the data of a source stream up to a specified offset, without copying it. The fork is independent: it has its own URL, TTL, closure state, and deletion lifecycle.
import { DurableStream } from "@durable-streams/client"
// Create a fork by passing fork headers via the headers option
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
"Stream-Fork-Offset": "1024", // optional; defaults to source tail
},
})
// Use a fresh handle for ongoing reads/writes
const fork = await DurableStream.connect({
url: "https://your-server.com/v1/stream/my-fork",
})
// Read — transparently returns inherited data followed by fork's own appends
const res = await fork.stream({ json: true })
const items = await res.json()
// Write — appends go only to the fork, source is untouched
await fork.append(JSON.stringify({ role: "user", text: "what if instead..." }))
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
// Stream-Fork-Offset omitted — defaults to source's current tail
},
})
Use a server-returned offset from a previous HEAD, GET, or POST response:
const source = await DurableStream.connect({
url: "https://your-server.com/v1/stream/my-source",
})
const head = await source.head()
// Fork at an offset you previously saved or received from the server
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
"Stream-Fork-Offset": savedOffset,
},
})
Reading a fork is identical to reading any stream. The fork transparently stitches inherited data from the source with the fork's own appends:
import { stream } from "@durable-streams/client"
const res = await stream({
url: "https://your-server.com/v1/stream/my-fork",
offset: "-1",
live: true,
})
res.subscribeJson(async (batch) => {
for (const item of batch.items) {
console.log(item) // inherited + fork's own data, in offset order
}
})
Appends work the same as any stream. Data goes only to the fork:
const fork = await DurableStream.connect({
url: "https://your-server.com/v1/stream/my-fork",
})
await fork.append(JSON.stringify({ event: "branched" }))
await DurableStream.delete({
url: "https://your-server.com/v1/stream/my-fork",
})
Deleting a fork decrements the source's reference count. If the source was soft-deleted and this was its last fork, the source is cleaned up too.
A fork has its own TTL and expiry. If the fork request provides Stream-TTL
or Stream-Expires-At, the fork uses those values. If omitted, the fork
inherits from the source: a source with a TTL passes its value on (the fork
runs its own sliding window), a source with Expires-At passes its hard
deadline on.
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
ttlSeconds: 3600, // fork's own TTL, independent of source
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
},
})
Wrong:
const fork = await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
"Stream-Fork-Offset": "1024",
},
})
// Fork headers are resent on every request from this handle
await fork.append(JSON.stringify({ event: "data" }))
Correct:
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
"Stream-Fork-Offset": "1024",
},
})
// Fresh handle — no fork headers on subsequent requests
const fork = await DurableStream.connect({
url: "https://your-server.com/v1/stream/my-fork",
})
await fork.append(JSON.stringify({ event: "data" }))
options.headers applies to every request on a handle. The fork headers are only meaningful on the initial PUT. Servers ignore them on reads and appends, but using a fresh handle keeps requests clean.
Source: packages/client/src/stream.ts
Wrong:
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
"Stream-Fork-Offset": "100", // made-up value
},
})
Correct:
// Use a server-returned offset
const source = await DurableStream.connect({
url: "https://your-server.com/v1/stream/my-source",
})
const head = await source.head()
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
"Stream-Fork-Offset": head.offset!, // server-returned offset
},
})
Offsets are opaque tokens. Fabricated values may return 400 Bad Request. Always use an offset from a previous HEAD, GET, or POST response.
Source: PROTOCOL.md section 6 (Offsets), section 4.2 (Stream forking)
Wrong:
// Source is application/json
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
contentType: "text/plain", // 409 Conflict!
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
},
})
Correct:
// Omit Content-Type to inherit from source
await DurableStream.create({
url: "https://your-server.com/v1/stream/my-fork",
headers: {
"Stream-Forked-From": "/v1/stream/my-source",
},
})
When forking, omit Content-Type to inherit it from the source. If provided, it must match the source's content type exactly or the server returns 409 Conflict.
Source: PROTOCOL.md section 4.2 (Stream forking)
Wrong:
try {
const res = await stream({ url: sourceUrl, offset: "-1", live: false })
const data = await res.json()
} catch (err) {
// Only checks for 404
if (err.statusCode === 404) console.log("Not found")
}
Correct:
try {
const res = await stream({ url: sourceUrl, offset: "-1", live: false })
const data = await res.json()
} catch (err) {
if (err.statusCode === 404) console.log("Not found")
if (err.statusCode === 410) console.log("Soft-deleted — has active forks")
}
When a source stream with active forks is deleted, it returns 410 Gone for all client operations. The source's data is retained internally for fork reads, but the source URL is no longer directly accessible.
Source: PROTOCOL.md section 4.2 (Soft-delete and lifecycle)
Targets @durable-streams/client v0.2.1.