← Concepts & practices
Pattern Concurrency, scheduling, and delivery

Async iteration & streams

Let the values arrive on purpose.

A shipment scanner has three updates, but the screen does not always want all three. Sometimes the consumer asks for the next value. Sometimes a live source publishes while the consumer waits. An async generator, a ReadableStream, and a Go channel can all carry values over time; they do not give the same control over demand, buffering, or stopping.

The judgment to keep

Name who controls the next value, who owns the source, and what closes or cancels it. Pull, push, and channel range describe delivery; they do not remove the need for a lifetime policy.

TypeScriptGo One scanner feed · three delivery shapes
Start with the feed

A list is easy until the next update does not exist yet.

An in-memory array is a fair first answer for the scanner’s three known updates. A screen can map it, a report can loop over it, and there is nothing to close. The pressure changes when the next scan may arrive later, the source may be live, or the reader stops before the end.

Async iteration gives a consumer a repeated request for the next value. The request may wait, and it can finish with done. A stream or channel lets a producer own delivery and announce completion, but it also creates a queue or a sender that must be stopped when nobody is listening.

Do not ask only “how do I receive the next value?” Ask “who decides when it exists, and who closes the source when I leave?”

Read the smallest pull sourceTypeScript · async generator, one yield at a time
streaming.ts · async generator
// Pull: the source does not prepare the next update until next() is requested.
export async function* pullUpdates(
	rows: readonly Update[],
	stats: FeedStats = newStats()
): AsyncGenerator<Update> {
	try {
		for (const row of rows) {
			stats.produced += 1;
			yield { ...row };
		}
	} finally {
		stats.closed = true;
	}
}

The yield pauses the generator until the consumer asks for another result. A for await loop supplies those requests and calls the iterator’s return path when it breaks early, giving the generator’s finally block a place to release a source.

Name the delivery shape

Same three updates, different owner of “next”.

With pull, the consumer asks and the source prepares. With push, the source publishes and the consumer reads what is available. A Go channel sits between those words: the producer sends, the receiver ranges, and an unbuffered send waits for a receiver. The visible values can match while the lifetime and queued work differ.

Shape 01

Async generator

Consumer demand calls next().

Owner
The consumer owns the iterator handle.
End
done, error, or return().
Watch
Early break must reach cleanup.
Shape 02

ReadableStream

Source enqueues; reader calls read().

Owner
The source owns its controller and resources.
End
close(), error, or cancel().
Watch
Queued values can outlive the screen.
Shape 03

Go channel range

Producer sends; receiver ranges until close.

Owner
The sender owns channel closure.
End
Close, context cancellation, or error policy.
Watch
A sender can block after the receiver leaves.
What the delivery shape promises
ShapeNext valueNormal endEarly stop
Async generatorConsumer requests itIterator returns donereturn() reaches finally
ReadableStreamSource enqueues itController closesReader cancels the source
Channel rangeProducer sends itSender closes channelContext lets sender stop safely
TypeScript · source and reader
streaming.ts · ReadableStream
export type PushFeed = Readonly<{
	stream: ReadableStream<Update>;
	publish(update: Update): void;
	close(): void;
}>;

// Push: the source owns the controller and publishes before a reader asks.
export function createPushFeed(stats: FeedStats = newStats()): PushFeed {
	let controller: ReadableStreamDefaultController<Update> | undefined;
	let active = true;
	const stream = new ReadableStream<Update>({
		start(next) {
			controller = next;
		},
		cancel() {
			active = false;
			stats.cancelled = true;
			stats.closed = true;
		}
	});

	return {
		stream,
		publish(update) {
			if (!active) return;
			stats.produced += 1;
			controller?.enqueue({ ...update });
		},
		close() {
			if (!active) return;
			active = false;
			stats.closed = true;
			controller?.close();
		}
	};
}

export async function consumePull(
	rows: readonly Update[],
	limit: number,
	stats = newStats()
): Promise<Update[]> {
	const result: Update[] = [];
	const iterator = pullUpdates(rows, stats);
	try {
		for await (const row of iterator) {
			result.push(row);
			stats.delivered += 1;
			if (result.length === limit) break;
		}
	} finally {
		await iterator.return?.(undefined);
	}
	return result;
}

export async function consumePush(
	stream: ReadableStream<Update>,
	limit: number,
	stats: FeedStats
): Promise<Update[]> {
	const result: Update[] = [];
	const reader = stream.getReader();
	try {
		while (result.length < limit) {
			const next = await reader.read();
			if (next.done) break;
			result.push(next.value);
			stats.delivered += 1;
		}
		if (result.length === limit && !stats.closed) await reader.cancel('consumer stopped');
	} finally {
		reader.releaseLock();
	}
	return result;
}
Go · channel range and cancellation
streaming.go · channel range
// Channel range is push delivery: the producer sends, and the receiver ranges.
func streamUpdates(ctx context.Context, rows []Update) <-chan Update {
	out := make(chan Update)
	go func() {
		defer close(out)
		for _, row := range rows {
			select {
			case out <- row:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func collectPull(rows []Update, limit int) []Update {
	feed := NewPullFeed(rows)
	defer feed.Close()
	result := make([]Update, 0, limit)
	for len(result) < limit {
		row, ok := feed.Next()
		if !ok {
			break
		}
		result = append(result, row)
	}
	return result
}

// collectChannel owns the context it hands the producer. Breaking the range
// leaves no receiver, so the deferred cancel is the producer's only way out.
func collectChannel(ctx context.Context, rows []Update, limit int) []Update {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	result := make([]Update, 0, limit)
	for row := range streamUpdates(ctx, rows) {
		result = append(result, row)
		if len(result) == limit {
			break
		}
	}
	return result
}
Follow the updates

Stop after two. See what was made, queued, and cleaned up.

Choose the delivery shape and decide whether the screen reads every scanner update. The lab keeps the data fixed; the changing facts are who triggers production, how many values exist, and what closes when the consumer leaves.

Three scanner updates

Change who controls the next value.

Runs a local stream model
Async generator · pull Stop after two updates
01consumer calls next() → scanner prepares picked
02consumer calls next() → scanner prepares loaded
03consumer breaks after two → iterator.return()
04delivered 2; delivered 3 is never produced
Produced

2 updates

Delivered

2 updates

Buffered

0 updates

Cleanup

for-await calls return(); the generator’s finally closes the source.

Pull keeps the third update unmade because the consumer never asks for it.

Watch for A pull source still needs a return/finally path when the consumer breaks early.

The controls change a local model; they do not open a network stream.
Read the complete comparisonTypeScript and Go · one scanner feed, two stopping choices

Pull boundary: TypeScript’s async generator and Go’s PullFeed each prepare one scanner update per request.

TypeScriptReading
streaming.ts
// Pull: the source does not prepare the next update until next() is requested.
export async function* pullUpdates(
	rows: readonly Update[],
	stats: FeedStats = newStats()
): AsyncGenerator<Update> {
	try {
		for (const row of rows) {
			stats.produced += 1;
			yield { ...row };
		}
	} finally {
		stats.closed = true;
	}
}
GoAlongside
streaming.go
// Pull: Next returns one update only when the consumer asks for it.
type PullFeed struct {
	rows   []Update
	index  int
	closed bool
}

func NewPullFeed(rows []Update) *PullFeed {
	return &PullFeed{rows: append([]Update(nil), rows...)}
}

func (feed *PullFeed) Next() (Update, bool) {
	if feed.closed || feed.index == len(feed.rows) {
		return Update{}, false
	}
	row := feed.rows[feed.index]
	feed.index++
	return row, true
}

func (feed *PullFeed) Close() {
	feed.closed = true
}
Name the contract

Choose the shape from demand and lifetime.

The tempting mistake is to choose “stream” because values arrive over time and stop thinking there. Say whether the consumer controls demand, whether the source can get ahead, and who stops a producer that no longer has a reader.

A paginated source should fetch the next page only when the consumer asks for another item.
A live source publishes updates while the screen is waiting for them.
A Go range consumer leaves after two messages.
A stream has a slow reader and a fast producer.
Feedback stays on this page; it is not saved.
A production boundary

Give every live value a source, an end, and an owner.

At the edge of a live feed, decide whether the consumer should pull the next page or whether a producer must publish as events occur. Then write the end of the contract: source exhausted, stream closed, consumer canceled, context done, or an error. A value arriving is only one part of the protocol.

Keep the source handle beside the consumer that owns it. A component can own an AbortController and stream reader; a Go handler can derive a context and let the producer select on it. If multiple consumers need the same events, name that as fan-out rather than quietly sharing one cursor.

Source

Who creates updates?

Generator, controller, device, or goroutine.

Delivery

Who controls next?

Consumer demand, producer publish, or channel send.

Lifetime

What ends it?

Done, close, cancel, context, or an explicit error.

The timeline owns an async iterator and returns it when the component unmounts or changes source.

ReactAlready in your code
textbook.tsx · owned async iterator
import { useEffect, useState } from 'react';

type Update = { id: string; status: string; note: string };
type UpdateSource = AsyncIterable<Update>;

export function ShipmentTimeline({ source }: { source: UpdateSource }) {
	const [updates, setUpdates] = useState<Update[]>([]);

	useEffect(() => {
		let active = true;
		const iterator = source[Symbol.asyncIterator]();
		setUpdates([]);

		async function consume() {
			while (true) {
				const next = await iterator.next();
				// A value that resolves after cleanup belongs to the old source.
				if (!active || next.done) break;
				setUpdates((current) => [...current, next.value]);
			}
		}

		void consume();
		return () => {
			active = false;
			void iterator.return?.(undefined);
		};
	}, [source]);

	return (
		<ol>
			{updates.map((update) => (
				<li key={update.id}>
					<strong>{update.status}</strong>: {update.note}
				</li>
			))}
		</ol>
	);
}
Build UIs?Your component already has a stream lifetime.

Where it already is in your components

A chat screen that appends response chunks, a log panel that reads a fetch body, and a “load more” control all consume values over time. The question is whether the effect owns the reader and returns it when the route, query, or component changes.

When you have to own it

When the source is live, expensive, or shared, keep cancellation beside the subscription. Abort the fetch, cancel the reader, return the iterator, or close the channel path. A mounted-state check can hide a late update; it does not stop the source that produced it.

Recognize it in UI code

A timeline is already a consumer with a lifetime.

Chat chunks

Read one response body or async iterable until done, then release the reader.

Live panels

Keep a subscription or stream handle with the effect that created it.

Load more

Use pull when the button, not the server, decides when another page is needed.

Pair streams with cancellation propagation ↗
The parts to watch

Values over time create more than one way to get stuck.

Breaking is not closing

Breaking a consumer loop stops that consumer. It only stops the source when the protocol carries that decision through return(), cancel(), or a context. Test the source after an early stop, not only the values the screen rendered.

A stream can be ahead of its reader

A push source may enqueue values before the reader asks. That can be useful for live data and costly for a slow screen. Set a queue or backpressure policy; choosing ReadableStream does not choose one for every producer.

Errors are an end signal with meaning

An exhausted feed, a canceled reader, and a failed source are different outcomes. Preserve the difference when the caller needs to retry, show a partial timeline, or record an incident.

One cursor is not fan-out

Two consumers sharing one iterator divide the values between them. If each panel needs every update, use an explicit broadcast or subscription owner with a policy for slow consumers.

Make the call

Choose the owner before choosing the API.

Conditions for each delivery shape
SituationReach forWhy
The caller decides how far to readAsync iterator / generatorDemand avoids work after an early stop.
A live source publishes while the UI waitsReadableStream or subscriptionSource delivery and reader cancellation are explicit.
A Go producer sends values to one consumerChannel and rangeClose and context make completion and cancellation visible.
Three values already live in memoryKeep the arrayThe simpler collection has no source lifetime to manage.

Reach for async iteration or a stream when values arrive over time and their lifetime matters.

Keep the array when the data is already complete and no consumer or source can stop independently.

Beside your own feed, can you point at the owner of the next value and the code that runs when the reader leaves?

Take the idea with you

Explain the stream without its name.

Say: “The consumer asks for the next shipment update; stopping returns the source. The live version publishes into a reader, and cancellation closes the producer path.” Then name the vocabulary in review: pull, push, completion, cancellation, buffering, and ownership.

Why
The screen reads scanner updates over time and may leave before the last one.
What
Pull through an async iterator when the reader sets the pace; a stream or channel when the source publishes on its own.
Constraint
Leaving the screen has to stop the source, not only the loop that reads it.
Fallback
Keep the array while every update is already in memory and nothing needs closing.
Reconsider when
A second panel needs the same updates, or the source gets ahead of a slow reader and needs a buffer policy.
Connections to follow nextRelated lessons