Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perf-frame-decoder-zero-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/start-client-core': patch
---

perf: zero-copy frame payload extraction in the client frame decoder. When a frame's bytes are fully contained in the first buffered chunk (the common case), return a subarray view instead of allocating a new buffer and copying. This is on the hot path for decoding streamed server-function responses and `RawStream` payloads.
19 changes: 19 additions & 0 deletions packages/start-client-core/src/client-rpc/frame-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,25 @@ export function createFrameDecoder(
function extractFlattened(count: number): Uint8Array {
if (count === 0) return EMPTY_BUFFER

// Fast path: the requested bytes are fully contained in the first buffered
// chunk (the common case — most frames arrive within a single network
// read). Return a subarray view instead of allocating a new buffer and
// copying `count` bytes. The view shares the chunk's backing ArrayBuffer,
// which is safe because buffered chunks are never mutated in place after
// being read from the network.
const first = bufferList[0]
if (first && first.length >= count) {
const result = first.subarray(0, count)
if (first.length === count) {
bufferList.shift()
} else {
bufferList[0] = first.subarray(count)
}
totalLength -= count
return result
}

// Slow path: the requested bytes span multiple chunks — flatten by copying.
const result = new Uint8Array(count)
let offset = 0
let remaining = count
Expand Down
46 changes: 46 additions & 0 deletions packages/start-client-core/tests/frame-decoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,5 +558,51 @@ describe('frame-decoder', () => {
const { done: finalDone } = await reader.read()
expect(finalDone).toBe(true)
})

it('should reassemble a chunk payload that spans many small reads', async () => {
// A large binary payload delivered in tiny network reads forces the
// multi-chunk (copy) path; the contiguous fast path must not change the
// reassembled bytes.
const payload = new Uint8Array(300)
for (let i = 0; i < payload.length; i++) payload[i] = i % 256

const jsonFrame = encodeJSONFrame('{"ref":11}')
const chunkFrame = encodeChunkFrame(11, payload)
const endFrame = encodeEndFrame(11)

const combined = new Uint8Array(
jsonFrame.length + chunkFrame.length + endFrame.length,
)
combined.set(jsonFrame, 0)
combined.set(chunkFrame, jsonFrame.length)
combined.set(endFrame, jsonFrame.length + chunkFrame.length)

const input = new ReadableStream<Uint8Array>({
start(controller) {
// 7-byte reads: smaller than the 9-byte header and the payload, so
// both header and payload span multiple buffered chunks.
for (let i = 0; i < combined.length; i += 7) {
controller.enqueue(combined.subarray(i, i + 7))
}
controller.close()
},
})

const { getOrCreateStream, jsonChunks } = createFrameDecoder(input)
const stream11 = getOrCreateStream(11)

const jsonReader = jsonChunks.getReader()
const { value: jsonValue } = await jsonReader.read()
expect(jsonValue).toBe('{"ref":11}')

const rawReader = stream11.getReader()
const received: Array<number> = []
while (true) {
const { done, value } = await rawReader.read()
if (done) break
if (value) received.push(...value)
}
expect(received).toEqual(Array.from(payload))
})
})
})