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
// 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.
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.
Async generator
Consumer demand calls next().
- Owner
- The consumer owns the iterator handle.
- End
done, error, orreturn().- Watch
- Early break must reach cleanup.
ReadableStream
Source enqueues; reader calls read().
- Owner
- The source owns its controller and resources.
- End
close(), error, orcancel().- Watch
- Queued values can outlive the screen.
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.
| Shape | Next value | Normal end | Early stop |
|---|---|---|---|
| Async generator | Consumer requests it | Iterator returns done | return() reaches finally |
| ReadableStream | Source enqueues it | Controller closes | Reader cancels the source |
| Channel range | Producer sends it | Sender closes channel | Context lets sender stop safely |
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;
} // 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
} 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.
Change who controls the next value.
2 updates
2 updates
0 updates
for-await calls return(); the generator’s finally closes the source.
Watch for A pull source still needs a return/finally path when the consumer breaks early.
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.
// 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;
}
} // 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
} 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.
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.
Who creates updates?
Generator, controller, device, or goroutine.
Who controls next?
Consumer demand, producer publish, or channel send.
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.
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.
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.
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.
Choose the owner before choosing the API.
| Situation | Reach for | Why |
|---|---|---|
| The caller decides how far to read | Async iterator / generator | Demand avoids work after an early stop. |
| A live source publishes while the UI waits | ReadableStream or subscription | Source delivery and reader cancellation are explicit. |
| A Go producer sends values to one consumer | Channel and range | Close and context make completion and cancellation visible. |
| Three values already live in memory | Keep the array | The 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?
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
- Backpressure & queues starts when a producer gets ahead; it gives the buffer and overload policy its own decision.
- Cancellation propagation follows how a stop signal crosses the source boundary.
- Structured concurrency asks who owns and joins work started alongside the consumer.