async/await and structured concurrency · advanced

AsyncAlgorithms: taming streams

merge, zip, combineLatest, debounce, throttle — the operator vocabulary for real-time feeds, and which one the ticker actually needs.

12 min read12 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Choose between merge, zip and combineLatest — and say what each one stalls on
  • Calm a firehose with throttle and know why debounce would silence it entirely
  • Batch and dedupe streams with chunks and removeDuplicates
  • Assemble the socket-to-screen pipeline a trading app actually ships
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

Your app shows one unified trade feed built from two exchange streams. A teammate combines them with zip(lseFeed, nyseFeed) — "pair them up, interleave the pairs". LSE then halts trading for lunch. What does the user see?

One package, the missing vocabulary

The async-sequences lesson gave you the raw material: any source can become an AsyncSequence, and for await consumes it. What the standard library doesn't ship is the combinators — the things Combine veterans reach for on day one. They live in swift-async-algorithms, Apple's open-source package:

// Package.swift — the modules-spm lesson's mechanics:
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")

import AsyncAlgorithms

The catalogue, grouped the way you'll actually reach for it:

FamilyOperatorsThe job
Combiningmerge, zip, combineLatest, chainseveral sequences → one
Timedebounce, throttle, AsyncTimerSequencewhen things emit
ShapingremoveDuplicates, chunks/chunked, adjacentPairs, compactedwhat gets through
Hand-offAsyncChannelback-pressured sends between tasks

Note the shape of the combining family: merge(a, b) is a free function taking the sequences, not a method on one of them — none of the inputs is privileged. The time and shaping families are methods, because they transform a single upstream.

merge, zip, combineLatest — what does each wait for?

The three combiners differ only in their waiting policy, and every real screen maps cleanly onto one of them:

merge waits for nothing. Elements surface in arrival order, whoever produced them. One feed for the UI out of N independent sources — the pretest's fix:

merge's contract: arrival order wins, nobody waits for a partner.

Five quotes, interleaved purely by time. Delete every LSE element and the NYSE ones are untouched — independence is the property merge preserves and zip destroys.

zip waits for a full set — element N pairs with element N. combineLatest waits only until every input has produced once, then re-emits on every change, carrying the latest of the others. The distinction is exactly the difference between "pair the events" and "recompute from current values" — predict it before you run it:

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

A GBP-priced holding: a price feed in USD and an fx feed of USD→GBP rates. The same five events go through both operators. How many pairs does zip make, and how many marks does combineLatest emit?

struct Event {
    let source: String
    let value: Double
}

let timeline = [
    Event(source: "price", value: 100.4),
    Event(source: "fx", value: 1.25),
    Event(source: "price", value: 101.2),
    Event(source: "price", value: 102.8),
    Event(source: "fx", value: 1.3),
]

// zip: strict lockstep — pair N is (Nth price, Nth rate)
var prices: [Double] = []
var rates: [Double] = []
var pairs = 0
for event in timeline {
    if event.source == "price" { prices.append(event.value) } else { rates.append(event.value) }
    if pairs < min(prices.count, rates.count) {
        print("zip     pair: \(prices[pairs]) @ \(rates[pairs])")
        pairs += 1
    }
}
print("zip made \(pairs) pairs, \(prices.count - pairs) price(s) waiting forever")

// combineLatest: remember the latest of each; emit on every change once both exist
var lastPrice: Double? = nil
var lastRate: Double? = nil
var marks = 0
for event in timeline {
    if event.source == "price" { lastPrice = event.value } else { lastRate = event.value }
    if let price = lastPrice, let rate = lastRate {
        print("latest  mark: \(price) @ \(rate)")
        marks += 1
    }
}
print("combineLatest made \(marks) marks")

The search box you already solved: the Combine-migration lesson built debounce from .task(id: query) + Task.sleep, where each keystroke's cancellation restarts the clock. The package spells the same contract as an operator on the query stream:

for await query in queryStream.debounce(for: .milliseconds(300)) {
    await search(query)      // fires only after 300ms of silence
}

Debounce's contract: a gap means done. Which is precisely why it is the wrong tool for the other hot stream in the building:

Check yourself

A live price ticker renders every tick and the UI can't keep up. A teammate reaches for .debounce(for: .milliseconds(300)) "to calm it down". On a busy trading day, what renders?

Throttle's semantics are worth owning precisely — one emission per window, and by default the latest value in the window (latest: true), because a ticker that renders stale prices at a calm rate would be worse than none:

One honest model of throttle: at most one render per window, always the freshest value.

Six ticks became three renders; the 101.09 dip and 101.15 blip inside the first window were superseded before anyone saw them. (Real throttle implementations differ in leading/trailing-edge details — the invariant to quote is at most one per interval, latest wins; with the package that's .throttle(for: .milliseconds(200)).)

The third time tool is the metronome: AsyncTimerSequencefor await over ticks, the structured replacement for a polling Timer (refresh the order book every second while the market's open, cancelled automatically when the consuming task dies).

Shaping: dedupe and batch

Two more operators carry their weight in any feed-driven app:

removeDuplicates() drops consecutive repeats — a quote stream that re-broadcasts an unchanged price shouldn't re-render the cell. Consecutive is the operative word: a price that returns later is a genuine change, which is why this is not a Set. (The first exercise makes you prove the difference.)

chunks(ofCount:) batches — 25 analytics events per upload instead of 25 uploads. And the fintech-shaped variant batches **by count or time**, whichever comes first, so a quiet stream still flushes:

// Ship every 25 events, or every 4 seconds, whichever comes first:
for await batch in events.chunked(by: .repeating(every: .seconds(4))) {
    try await upload(batch)
}

If that "batch, but flush on a deadline" shape feels familiar, it should — it's the offline-sync lesson's outbox drain policy, reborn as a stream operator.

The pipeline, end to end

Put the lesson together into the thing a trading app actually ships — socket to screen:

// 1. Bridge (the async-sequences lesson): socket callbacks -> AsyncStream
func quoteStream() -> AsyncStream<Quote> {
    AsyncStream(bufferingPolicy: .bufferingNewest(64)) { continuation in
        socket.onMessage = { continuation.yield(Quote(decoding: $0)) }   // the Codable lesson's boundary
        continuation.onTermination = { _ in socket.disconnect() }        // consumer died -> close the socket
    }
}

// 2. Shape it (this lesson):
let renderFeed = quoteStream()
    .removeDuplicates()                      // unchanged price -> no work
    .throttle(for: .milliseconds(250))       // humane rate, latest value

// 3. Consume it (the async-await lesson): lifetime owned by the view
.task {
    for await quote in renderFeed {
        model.apply(quote)                   // @MainActor by the actors lesson's rules
    }
}

Every line is a lesson you've already done — the bridge with its termination cleanup, the buffering policy (.bufferingNewest: a UI wants fresh, not history), the operators, the .task that cancels the whole pipeline when the view disappears, and the socket closing because of that cancellation, all the way down. That cascade — view gone → task cancelled → stream terminated → socket closed — is the answer to "how do you make sure the WebSocket doesn't leak", and it's four lessons shaking hands.

removeDuplicates is not a Set

Write the code

Suppress consecutive repeats from a quote feed — a price that returns later is a real change and must render again.

Solution unlocks after 3 attempts

zip's arithmetic: who is left waiting?

Write the code

Implement zip's lockstep pairing for a trade/confirmation reconciler — and report what's left stranded, because the stall is the point.

Solution unlocks after 3 attempts
Connect itSaved on this device. Never graded.

Map the operators onto a trading app you'd design: the search field, the live ticker header, a GBP-converted portfolio total, order-status pairing, and batched analytics. For each surface: which operator, what it waits for, what it drops, and what breaks if you swap it for its nearest neighbour (merge↔zip, debounce↔throttle). Two sentences each, as you'd say them in a system-design interview.

Checkpoint

You can now:

  • Choose merge/zip/combineLatest by waiting policy, and predict what each emits from a timeline
  • Calm a firehose with throttle — and explain why debounce silences it instead
  • Dedupe consecutively and batch by count-or-deadline
  • Tell the full socket → stream → operators → .task lifetime story without a leak

Next up: the tasks running all these pipelines — Task, Task.detached, and who owns an unstructured lifetime.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.