How to Build a Deterministic Story Generation Pipeline with Swift Concurrency

Most Swift concurrency tutorials stop at fetching JSON from a server. You get a single async function, a try await call, and maybe a Task if the author is feeling generous. That is not the world you build in. Production systems are pipelines—long-running, stateful, multi-stage processes where data flows from one stage to the next, where backpressure matters, where partial results need to surface before the entire computation finishes, and where cancellation must propagate cleanly without leaving orphaned work or corrupted state.

This article uses a fictional AI story generator as a concrete scaffold for exploring those patterns. The generator is not the subject. The subject is how to design Swift concurrency pipelines that compose, how to handle partial results and error recovery, and how to profile and optimize actor-based state management under load. If you have ever built a processing pipeline that outgrew a single async function, the patterns here apply directly to your codebase.

We will model a pipeline that accepts a user’s creative brief—genre, tone, character archetypes, setting—and produces a complete story draft in stages: character names, a title, a plot outline, and finally prose paragraphs. Each stage is a discrete processing node. Some stages produce multiple candidates (ten names, ten titles) that the user can select from before the pipeline continues. The entire system must remain responsive, cancellable, and deterministic enough to test.

The Pipeline as a Dataflow Problem

Before writing any Swift, define the shape of the data. A story generation request is a value type carrying all the inputs the pipeline needs:

struct GenerationRequest: Sendable {
    var genre: Genre
    var tone: Tone
    var archetypes: [CharacterArchetype]
    var setting: String
    var conflictDescription: String
    var selectedNames: [CharacterName]?
    var selectedTitle: TitleCandidate?
    var plotPoints: [PlotPoint]?
}

Notice that GenerationRequest is mutable state that travels through the pipeline. Early stages write partial results into it—selected names, a chosen title—and later stages read those fields to condition their output. This is not a fire-and-forget RPC. It is a state machine where each transition depends on accumulated context.

The pipeline itself is a sequence of stages. Each stage is an AsyncSequence that consumes the previous stage’s output and produces its own. This is the key insight: pipelines are not chains of async function calls. They are chains of AsyncSequence transformations, because stages need to emit multiple values over time (candidates, progress updates, final results) and because backpressure must flow from the consumer backward through the chain.

Consider a real-world analogue. A tool like Reedsy’s Character Name Generator takes archetype, personality, gender, setting, and cultural origin as inputs, then returns ten names with explanations. That is a multi-input, batch-output stage. Our pipeline models exactly that: a CharacterNameStage that emits a stream of CharacterNameCandidate values, then waits for the user to select before proceeding.

Stage One: Character Name Generation with Backpressure

Define the stage as a type that conforms to AsyncSequence. This gives the consumer pull-based control: the stage only produces a candidate when the consumer asks for the next value. If the consumer is a SwiftUI view that displays candidates one at a time, the generator never runs ahead of the UI’s ability to render.

struct CharacterNameStage: AsyncSequence {
    typealias Element = CharacterNameCandidate

    let request: GenerationRequest
    let provider: any NameProvider

    struct AsyncIterator: AsyncIteratorProtocol {
        var remaining: Int
        var provider: any NameProvider
        var request: GenerationRequest

        mutating func next() async throws -> CharacterNameCandidate? {
            guard remaining > 0 else { return nil }
            remaining -= 1
            return try await provider.generateName(
                archetype: request.archetypes.randomElement()!,
                setting: request.setting
            )
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(remaining: 10, provider: provider, request: request)
    }
}

Two design choices matter here. First, the stage does not own a Task. It is a passive sequence that the consumer drives. This means cancellation is handled by the consumer simply stopping iteration—no explicit Task.cancel() required. Second, the NameProvider is injected as an existential (any NameProvider), which lets you swap a real AI-backed provider for a deterministic fake during testing. The existential does incur an allocation, but at the stage boundary—once per pipeline construction, not once per element—so the cost is negligible.

The consumer side, perhaps a SwiftUI view model, iterates the sequence inside a Task and publishes candidates as they arrive:

@MainActor
final class NameSelectionModel: ObservableObject {
    @Published var candidates: [CharacterNameCandidate] = []
    private var generationTask: Task?

    func startGeneration(request: GenerationRequest) {
        generationTask = Task { [weak self] in
            guard let self else { return }
            let stage = CharacterNameStage(request: request, provider: LiveNameProvider())
            do {
                for try await candidate in stage {
                    self.candidates.append(candidate)
                }
            } catch {
                self.handleError(error)
            }
        }
    }

    func cancel() {
        generationTask?.cancel()
    }
}

The for try await loop is the backpressure mechanism. The stage’s iterator suspends at each next() call until the provider returns. If the provider is slow, the loop blocks on that element. The UI can display partial results immediately because candidates is appended incrementally. When the user selects a name and calls cancel(), the task cancels, the loop exits, and no further provider calls are made. No orphaned work.

Composing Stages with AsyncSequence Transformations

A single stage is straightforward. The power of the pattern emerges when you compose stages. After name selection, the pipeline needs a title generation stage. A tool like Reedsy’s Book Title Generator takes genre, conflict description, comp titles, and a mode (commercial vs. literary) and returns ten titles with explanations. That is another batch-output stage, but it depends on the names selected in the previous stage.

We can model the dependency by making the title stage’s input a transformed version of the name stage’s output. Instead of chaining imperative calls, we use AsyncSequence combinators:

func titleStage(from nameResult: NameSelectionResult, request: GenerationRequest) -> TitleGenerationStage {
    var enrichedRequest = request
    enrichedRequest.selectedNames = nameResult.names
    return TitleGenerationStage(request: enrichedRequest, provider: LiveTitleProvider())
}

This is a pure function from accumulated state to the next stage. No shared mutable state between stages. The pipeline’s progress is represented by the evolving GenerationRequest value, which is passed forward immutably at each transition. If you need to persist the pipeline state across app launches, you can encode GenerationRequest to disk at each stage boundary—it is a single Codable value that captures the entire pipeline’s progress.

For stages that need to emit progress updates during generation—say, a prose generation stage that emits paragraphs as they are written—you can use an AsyncStream with a continuation:

struct ProseGenerationStage: AsyncSequence {
    typealias Element = ProseEvent

    enum ProseEvent {
        case paragraph(String)
        case progress(Double)
        case complete
    }

    let request: GenerationRequest
    let provider: any ProseProvider

    struct AsyncIterator: AsyncIteratorProtocol {
        var stream: AsyncStream.Iterator

        mutating func next() async -> ProseEvent? {
            await stream.next()
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        var continuation: AsyncStream.Continuation!
        let stream = AsyncStream { continuation = $0 }
        let provider = self.provider
        let request = self.request
        Task {
            await provider.generateProse(for: request) { event in
                continuation.yield(event)
            }
            continuation.finish()
        }
        return AsyncIterator(stream: stream.makeAsyncIterator())
    }
}

Here the stage owns a Task internally because the provider uses a callback-based API. The AsyncStream bridges that callback into the AsyncSequence world. The consumer still drives iteration via for try await, and if the consumer cancels its enclosing task, the stream’s continuation is finished, which causes the provider’s callback to stop being invoked (assuming the provider checks Task.isCancelled or the continuation’s finish status).

Actor-Based State Management for Pipeline Coordination

So far the pipeline is a linear chain of sequences. Real pipelines branch. After name generation, the user might want to regenerate a single name without restarting the entire pipeline. That requires mutable shared state: a registry of generated candidates that multiple UI interactions can read and update.

An actor is the correct tool for this shared mutable state, but the design must avoid turning the actor into a bottleneck. The mistake is to put the generation logic inside the actor. That serializes all generation work through the actor’s executor, destroying parallelism. Instead, the actor should hold only the registry—the minimal shared state—and generation work should run on unstructured tasks outside the actor.

actor GenerationRegistry {
    private var candidates: [UUID: CharacterNameCandidate] = [:]
    private var selectedIDs: Set = []
    private var activeTasks: [UUID: Task] = [:]

    func storeCandidate(_ candidate: CharacterNameCandidate) {
        candidates[candidate.id] = candidate
    }

    func selectCandidate(id: UUID) {
        selectedIDs.insert(id)
    }

    func selectedCandidates() -> [CharacterNameCandidate] {
        selectedIDs.compactMap { candidates[$0] }
    }

    func registerTask(_ task: Task, for id: UUID) {
        activeTasks[id] = task
    }

    func cancelTask(for id: UUID) {
        activeTasks[id]?.cancel()
        activeTasks[id] = nil
    }
}

The registry actor is a minimal lock. It stores candidates, tracks selections, and holds references to in-flight generation tasks so they can be cancelled individually. The generation work itself runs in unstructured Tasks created outside the actor:

func regenerateName(id: UUID, request: GenerationRequest, registry: GenerationRegistry) {
    let task = Task {
        let provider = LiveNameProvider()
        let candidate = try? await provider.generateName(
            archetype: request.archetypes.randomElement()!,
            setting: request.setting
        )
        if let candidate {
            await registry.storeCandidate(candidate)
        }
    }
    Task { await registry.registerTask(task, for: id) }
}

This pattern—actor as registry, unstructured tasks as workers—keeps the actor’s critical section tiny. The storeCandidate and registerTask methods are O(1) dictionary operations. The expensive network or AI inference work happens concurrently across multiple tasks, not serialized through the actor.

Error Recovery and Partial Results

AI generation is unreliable. A provider call may time out, return malformed output, or exceed a rate limit. The pipeline must surface partial results and allow recovery without restarting from scratch.

The pattern is to model each stage’s output as a stream of Result values, not raw successes. The consumer can then handle failures per-element:

enum GenerationEvent: Sendable {
    case candidate(Success)
    case error(Error, retryable: Bool)
    case progress(Double)
}

struct RobustCharacterNameStage: AsyncSequence {
    typealias Element = GenerationEvent
    // ...
    struct AsyncIterator: AsyncIteratorProtocol {
        var remaining: Int
        var provider: any NameProvider
        var request: GenerationRequest

        mutating func next() async throws -> GenerationEvent? {
            guard remaining > 0 else { return nil }
            remaining -= 1
            do {
                let name = try await provider.generateName(
                    archetype: request.archetypes.randomElement()!,
                    setting: request.setting
                )
                return .candidate(name)
            } catch {
                return .error(error, retryable: true)
            }
        }
    }
}

The consumer can now decide per-element whether to retry, skip, or abort. A SwiftUI view model might accumulate candidates and errors separately, showing a “Regenerate” button next to failed slots:

for try await event in stage {
    switch event {
    case .candidate(let name):
        candidates.append(name)
    case .error(let error, let retryable):
        if retryable {
            failedSlots.append(FailedSlot(index: candidates.count, error: error))
        }
    case .progress(let pct):
        self.progress = pct
    }
}

This design keeps the pipeline deterministic for testing. You can inject a NameProvider that deliberately fails on the third call and verify that the consumer surfaces exactly one failed slot at index 2. No flaky tests.

Profiling Actor Contention Under Load

When a user rapidly regenerates multiple names, the registry actor can become a contention point. Each storeCandidate call is an async suspension point that hops to the actor’s executor. Under high throughput, those hops add up.

Profile this with Instruments’ Swift Concurrency template. Look for threads blocked on actor executors. If you see the registry actor’s executor saturated while worker tasks are idle, you have a bottleneck. The fix is to batch updates. Instead of storing each candidate individually, collect them in an unstructured task and flush them in a single actor call:

actor BatchRegistry {
    private var candidates: [UUID: CharacterNameCandidate] = [:]

    func storeBatch(_ batch: [CharacterNameCandidate]) {
        for candidate in batch {
            candidates[candidate.id] = candidate
        }
    }
}

The worker now accumulates results locally and flushes periodically:

var batch: [CharacterNameCandidate] = []
for try await event in stage {
    if case .candidate(let name) = event {
        batch.append(name)
        if batch.count >= 5 {
            await registry.storeBatch(batch)
            batch.removeAll(keepingCapacity: true)
        }
    }
}
if !batch.isEmpty {
    await registry.storeBatch(batch)
}

This reduces actor hops by a factor of five. The tradeoff is latency: candidates are not visible in the registry until the batch flushes. For a UI that displays candidates incrementally, you can publish from the worker’s local array directly to the view model, and use the registry only for persistence and cross-session state. The actor becomes a write-behind cache, not a read-through cache.

Cancellation Propagation Across Stage Boundaries

Cancellation in structured concurrency is cooperative. A parent task’s cancellation flag is set, and child tasks check it. But when you compose AsyncSequence stages, the cancellation boundary is the for try await loop. If the consumer cancels its task, the loop exits, and the stage’s iterator is deallocated. Any resources the iterator holds are released.

This works for simple stages. For stages that own internal Tasks (like the ProseGenerationStage above), you must ensure those tasks also cancel. The pattern is to store the internal task and cancel it in the iterator’s deinit or in a cancel() method exposed to the consumer:

struct ProseGenerationStage: AsyncSequence {
    // ...
    class AsyncIterator: AsyncIteratorProtocol {
        var stream: AsyncStream.Iterator
        var internalTask: Task?

        func cancel() {
            internalTask?.cancel()
        }

        deinit {
            cancel()
        }

        mutating func next() async -> ProseEvent? {
            await stream.next()
        }
    }
}

The consumer calls cancel() on the iterator when the user aborts generation. Because the iterator is a class (reference type), the consumer can hold a reference to it alongside the for try await loop. This is one of the rare cases where a class is the right choice for an iterator: you need shared ownership of the cancellation handle.

Testing Determinism

The entire pipeline must be testable without network calls or AI inference. Inject providers that return predetermined values. For the name stage, a FakeNameProvider that cycles through a fixed array:

struct FakeNameProvider: NameProvider {
    var names: [CharacterNameCandidate]
    var index = 0

    mutating func generateName(archetype: CharacterArchetype, setting: String) async throws -> CharacterNameCandidate {
        defer { index = (index + 1) % names.count }
        return names[index]
    }
}

Now you can write a test that verifies the pipeline produces exactly ten candidates in order, that cancellation after five candidates stops iteration, and that error injection at a specific index produces the expected .error event. Because the pipeline is a composition of AsyncSequences driven by a consumer loop, the test is a simple for try await with assertions:

func testNameGenerationProducesTenCandidates() async throws {
    let provider = FakeNameProvider(names: .mock)
    let stage = CharacterNameStage(request: .mock, provider: provider)
    var count = 0
    for try await candidate in stage {
        count += 1
        XCTAssertEqual(candidate.name, .mock[count - 1].name)
    }
    XCTAssertEqual(count, 10)
}

No timeouts, no flakiness, no network. The test runs in milliseconds and is perfectly repeatable.

Putting It Together: The Pipeline Coordinator

The top-level coordinator is an actor that owns the pipeline’s state machine. It exposes methods for each user action—start generation, select name, regenerate name, select title, generate prose—and transitions through pipeline stages by creating the appropriate AsyncSequence and driving it with a Task.

actor StoryPipelineCoordinator {
    private var request: GenerationRequest
    private var registry: GenerationRegistry
    private var currentTask: Task?

    func startNameGeneration() async -> AsyncStream> {
        let (stream, continuation) = AsyncStream>.makeStream()
        let stage = RobustCharacterNameStage(request: request, provider: LiveNameProvider())
        currentTask = Task {
            do {
                for try await event in stage {
                    continuation.yield(event)
                    if case .candidate(let name) = event {
                        await registry.storeCandidate(name)
                    }
                }
                continuation.finish()
            } catch {
                continuation.yield(.error(error, retryable: false))
                continuation.finish()
            }
        }
        return stream
    }

    func selectNames(_ ids: [UUID]) async {
        for id in ids {
            await registry.selectCandidate(id: id)
        }
        request.selectedNames = await registry.selectedCandidates()
    }

    func startTitleGeneration() async -> AsyncStream> {
        // Similar pattern, using enriched request with selected names
    }
}

The coordinator is the only actor that mutates the pipeline’s state machine. It does not perform generation work itself—it creates sequences and tasks, then publishes results through AsyncStream continuations. This keeps the actor’s methods fast and non-blocking.

This architecture mirrors what you would find if you examined an AI story generator that fits the draft workflow of a professional writer: a multi-stage process where each stage produces candidates, the user selects, and the accumulated context feeds the next stage. The Swift concurrency patterns here—AsyncSequence composition, actor registries, batch flushing, error-as-event, and injectable providers—are what make that pipeline deterministic, testable, and responsive under load.

When Not to Use This Pattern

The AsyncSequence pipeline pattern shines when stages emit multiple values over time and when backpressure matters. It is overkill for a single request-response flow. If your “pipeline” is just three sequential async function calls, chain them with try await and move on. The complexity of custom AsyncSequence types pays off only when you need incremental emission, cancellation mid-stream, or composition of stages from different modules.

Also, be wary of over-actorizing. The registry actor in this design is justified because multiple concurrent tasks mutate shared state. If your pipeline is single-threaded by design—one user, one generation at a time—an actor is unnecessary. A plain @MainActor class with a dictionary is simpler and faster.

Key Takeaways

  • Model pipelines as AsyncSequence chains, not async function chains. Sequences give you backpressure, incremental emission, and composability.
  • Keep actors minimal. Use them as registries for shared mutable state, not as execution hosts for expensive work.
  • Batch actor mutations when throughput matters. Profile with Instruments to find contention before optimizing.
  • Surface errors as stream events, not thrown exceptions. This lets consumers handle partial failures without aborting the entire pipeline.
  • Inject providers for testability. A pipeline built on AsyncSequence with injected dependencies is trivially deterministic in tests.
  • Propagate cancellation explicitly when stages own internal Tasks. A deinit cancel is cheap insurance against orphaned work.

The fictional story generator is a useful scaffold because it forces you to confront the hard parts of pipeline design: partial results, user interaction mid-stream, error recovery, and state accumulation across stages. The patterns you learn building it apply to any long-running, multi-stage Swift processing system—whether you are generating text, transcoding video, or indexing a corpus. The concurrency primitives are the same. The discipline is the same. Write for the reader first, for the compiler second, and for the next engineer who will debug your pipeline at 2 a.m. last.