Streaming LLM Response Consumer With Cancel

When a user navigates away mid-completion we still get billed for the remaining tokens. This is the SSE-style consumer I wrote that decodes JSON deltas, exposes a `cancel()` that aborts the request, and never leaks a reader on errors.

JavaScript
Frontend
3 snippets
openai
sse
networking
error-handling
oliviadelgado

By @oliviadelgado

May 15, 2026

·

Updated August 6, 2026

251 views

3

4.4 (12)

// streamCompletion: fetches a streaming endpoint, decodes SSE-shaped
// `data: {json}\n\n` deltas, yields each delta as it arrives. cancel() aborts
// the in-flight request so we stop being billed for unrendered tokens.
function streamCompletion(url, body) {
    const controller = new AbortController();
    let cancelled = false;

    async function* iterator() {
        const res = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
            body: JSON.stringify(body),
            signal: controller.signal,
        });
        if (!res.ok || !res.body) {
            throw new Error('stream HTTP ' + res.status);
        }
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';
        try {
            while (true) {
                const { value, done } = await reader.read();
                if (done) break;
                buffer += decoder.decode(value, { stream: true });
                let nl;
                while ((nl = buffer.indexOf('\n\n')) >= 0) {
                    const frame = buffer.slice(0, nl);
                    buffer = buffer.slice(nl + 2);
                    if (!frame.startsWith('data: ')) continue;
                    const payload = frame.slice(6).trim();
                    if (payload === '[DONE]') return;
                    try {
                        yield JSON.parse(payload);
                    } catch (err) {
                        // Drop a malformed frame rather than killing the stream.
                        console.warn('stream: skipped malformed frame');
                    }
                }
            }
        } finally {
            // Always release the reader so the underlying socket is recycled,
            // even when the consumer breaks out early or throws.
            try { reader.releaseLock(); } catch (_) {}
        }
    }

    return {
        [Symbol.asyncIterator]: iterator,
        cancel() {
            cancelled = true;
            controller.abort();
        },
        get cancelled() { return cancelled; },
    };
}

// Demo: build a hand-rolled streaming body so the snippet runs in any JS host.
// (The vm sandbox does not have ReadableStream; the production browser does.)
const encoder = new TextEncoder();
const frames = [
    'data: {"delta":"Hello "}\n\n',
    'data: {"delta":"world"}\n\n',
    'data: {"delta":"!"}\n\n',
    'data: [DONE]\n\n',
];
function makeStreamBody(chunks) {
    let i = 0;
    return { getReader: () => ({
        read: () => i >= chunks.length
            ? Promise.resolve({ value: undefined, done: true })
            : Promise.resolve({ value: encoder.encode(chunks[i++]), done: false }),
        releaseLock: () => {},
    })};
}
const originalFetch = typeof fetch === 'function' ? fetch : null;
globalThis.fetch = async () => ({ ok: true, status: 200, body: makeStreamBody(frames) });

(async () => {
    const stream = streamCompletion('/v1/chat', { prompt: 'hi' });
    let text = '';
    for await (const chunk of stream) {
        text += chunk.delta;
        console.log('got delta:', JSON.stringify(chunk));
    }
    console.log('final:', text);
    if (originalFetch) globalThis.fetch = originalFetch;
})();

The consumer is an async generator wrapped in an object that exposes cancel(). The generator body owns the buffer, the SSE frame parser, and the reader; the outer object owns the AbortController so the caller can stop the request from outside the loop. The try / finally is what guarantees we never leak a reader: if the consumer throws or breaks out of for await, the finally releases the lock and the runtime can close the socket. I keep the malformed-frame path as a console.warn rather than a throw because OpenAI and Anthropic both occasionally emit a partial frame near the end of long completions, and killing the stream over a single bad frame is worse than dropping it.