Greetings, traveler!p
Modern iOS applications often need to perform several asynchronous operations before they can move to the next state.
Application startup is an obvious example. You may need to restore a session, load a user profile, fetch remote configuration, restore preferences, and prepare data for the first screen.
The straightforward implementation is sequential:
let token = try await authenticationService.authenticate()
let profile = try await profileService.loadProfile(token: token)
let preferences = try await preferencesService.loadPreferences()
sessionStore.save(token)
profileStore.save(profile)
preferencesStore.save(preferences)This is easy to follow, but it may serialize work that does not need to be serialized.
Once authentication finishes, loading the profile and preferences may be completely independent. Waiting for one before starting the other adds unnecessary time to the flow.
Running independent work concurrently
Swift already gives us good tools for concurrent work. For a small number of independent operations, async let may be enough:
let token = try await authenticationService.authenticate()
async let profile = profileService.loadProfile(token: token)
async let preferences = preferencesService.loadPreferences()
let loadedProfile = try await profile
let loadedPreferences = try await preferencesTaskGroup gives us more flexibility when the amount of work is dynamic.
The problem becomes more interesting when the flow is neither fully sequential nor fully parallel. Consider an application startup sequence like this:
Authenticate
|
+-- Load Profile
|
+-- Load Preferences
|
+-- Load Remote Configuration
The last three operations can start after authentication. They may finish in any order.
At the same time, you may want to apply their results in a predictable order:
Authenticate
Profile
Preferences
Remote Configuration
The network requests can run concurrently, while changes to application state remain serialized.
This distinction between preparing work and applying its result is useful in more places than application startup.
Where this pattern can help
Session restoration often has the same shape. After restoring credentials, the application may start loading account data, permissions, cached state, and configuration at the same time.
Onboarding flows can also contain dependencies. One step may validate an account before several independent requests can start.
A complex feature may need to load configuration first and then prepare several pieces of data before constructing its final state.
Preloading is another example. You may want to fetch data as early as possible, but expose it to the rest of the application only when previous initialization steps have completed.
You can implement all of these cases directly with Swift concurrency. The difficulty is usually not starting tasks. It is coordinating dependencies, preserving execution order, handling retries, pausing the flow, and keeping the resulting state machine understandable.
This was the problem I wanted to address with Flowline.
Flowline
Flowline is a small Swift library for preparing dependent operations concurrently while delivering their prepared values to an external executor in deterministic order.
The main idea is to separate each operation into two stages:
prepare
|
v
prepared value
|
v
execute
Preparation can happen concurrently when dependencies allow it. Execution remains serialized and follows the order in which operations were configured.
A FlowOperation contains an identifier, optional preparation dependencies, and an asynchronous preparation closure.
A basic startup flow can look like this:
enum StartupStep: Hashable, Sendable {
case authenticate
case loadProfile
case loadPreferences
}
enum PreparedData: Sendable {
case session(String)
case profile(String)
case preferences([String])
}Now we can describe the operations:
let operations: [FlowOperation<StartupStep, PreparedData>] = [
FlowOperation(id: .authenticate) {
let token = try await authenticationService.authenticate()
return .ready(.session(token))
},
FlowOperation(
id: .loadProfile,
preparationDependencies: [.authenticate],
prepareWithInputs: { inputs in
guard case let .ready(.session(token))? = inputs[.authenticate] else {
throw StartupError.missingSession
}
let profile = try await profileService.loadProfile(token: token)
return .ready(.profile(profile.name))
}
),
FlowOperation(
id: .loadPreferences,
preparationDependencies: [.authenticate]
) {
let preferences = try await preferencesService.loadPreferences()
return .ready(.preferences(preferences))
}
]Both loadProfile and loadPreferences depend on authenticate.
Once authentication preparation finishes, Flowline can start preparing both operations. loadProfile can also read the token produced by authenticate.
It does not have to wait until the authentication result has passed through the external executor. A preparation dependency is satisfied when its preparation finishes or returns .skip.
Passing data between preparation steps
Some dependencies are only about ordering. For example, an operation may need to wait until authentication succeeds but may not need the authentication result itself.
In that case, the shorter initializer is enough:
FlowOperation(
id: .loadPreferences,
preparationDependencies: [.authenticate]
) {
let preferences = try await preferencesService.loadPreferences()
return .ready(.preferences(preferences))
}When an operation needs the value produced by a prerequisite, it can use prepareWithInputs:
FlowOperation(
id: .loadProfile,
preparationDependencies: [.authenticate],
prepareWithInputs: { inputs in
guard case let .ready(.session(token))? = inputs[.authenticate] else {
throw StartupError.missingSession
}
let profile = try await profileService.loadProfile(token: token)
return .ready(.profile(profile.name))
}
)PreparationInputs is an immutable snapshot containing results only for explicitly declared preparation prerequisites.
This avoids using shared mutable state just to pass a value from one concurrent task to another.
Preparing concurrently, executing sequentially
Preparing a value does not immediately apply it to the application. That responsibility belongs to the execute closure:
let processor = FlowProcessor(
operations: operations,
execute: { step, data in
switch (step, data) {
case let (.authenticate, .session(token)):
sessionStore.save(token)
case let (.loadProfile, .profile(name)):
profileStore.save(name)
case let (.loadPreferences, .preferences(values)):
preferencesStore.save(values)
default:
return .cancel
}
return .proceed
}
)Suppose profile loading takes two seconds and preferences loading takes half a second.
The preparation timeline may look like this:
Authenticate
|
+-- Profile -----------+
|
+-- Preferences --+
Preferences become ready first. Flowline still does not send them to execute before the profile because execution follows configuration order:
Authenticate
Profile
PreferencesOnly one prepared value is handled by execute at a time. This lets expensive asynchronous work start early without making the order of side effects depend on network timing.
Skipping work
A preparation closure does not always have to produce a value. It can return:
return .skipA skipped operation is not passed to execute.
It still counts as prepared, so operations that depend on it can continue.
This is useful for optional initialization:
FlowOperation(id: .restorePromotion) {
guard promotionService.shouldRestorePromotion else {
return .skip
}
let promotion = try await promotionService.load()
return .ready(.promotion(promotion))
}The rest of the flow does not need separate branching logic just because this step is optional.
Handling preparation failures
Preparation may fail before an operation reaches its execution position. Flowline retains that failure and resolves it when the operation reaches its place in the ordered flow.
You can provide a resolvePreparationFailure handler:
let processor = FlowProcessor(
operations: operations,
execute: execute,
resolvePreparationFailure: { failure in
let shouldRetry = await recoveryPresenter.shouldRetry(
step: failure.operationID,
error: failure.underlyingError
)
return shouldRetry ? .retry : .cancel
}
)The handler can return:
.retry
.skip
.skipAndPause
.cancelThis is useful when retrying is a flow-level decision. For example, profile loading fails and the application asks the user whether to try again.
Technical networking retries are a different problem. A service that retries transient HTTP failures with backoff should normally keep that logic inside the networking or service layer rather than moving it into Flowline. Flowline’s .retry is better suited to restarting an operation as part of the ordered application flow.
Pausing and resuming a flow
A processor can also be paused externally:
await processor.pause()and resumed later:
await processor.resume()While paused, Flowline stops scheduling new preparations and stops ordered execution.
Preparations that are already running are allowed to finish, and their results are retained until the processor resumes.
This can be useful when the application needs to temporarily stop initialization without throwing away work that has already started.
An operation can also request a pause by returning .pause from execute:
let processor = FlowProcessor(
operations: operations,
execute: { step, data in
apply(step, data)
if step == .requestConfirmation {
await MainActor.run {
coordinator.showConfirmation()
}
return .pause
}
return .proceed
}
)
coordinator.onConfirmation = {
Task {
await processor.resume()
}
}Execution decisions are limited to .proceed, .pause, and .cancel.
Cancellation follows Swift concurrency
The task awaiting run() owns the lifetime of the processor:
let task = Task {
try await processor.run()
}Cancelling that task cancels the flow:
task.cancel()
let outcome = try await task.valueFlowline therefore does not introduce a separate cancellation-token abstraction around Swift concurrency.
Controlling preparation concurrency
Starting everything as soon as possible is not always desirable. A startup flow containing twenty network operations probably should not create twenty requests at exactly the same time.
FlowProcessor therefore limits concurrent preparations:
let processor = FlowProcessor(
operations: operations,
execute: execute,
maximumConcurrentPreparations: 4
)The default limit is eight. The appropriate value depends on the resource being used by the preparation work.
Dependencies and configuration order
Flowline intentionally does not try to be a general purpose graph execution engine. Preparation dependencies must point to operations that appear earlier in the configured array.
For example:
[
authenticate,
loadProfile,
loadPreferences
]loadProfile may depend on authenticate.
An operation cannot depend on another operation declared later in the array.
This makes the same array define both the valid dependency direction and the final execution order. Flowline validates duplicate identifiers and invalid prerequisite ordering when building the flow.
The restriction keeps the model relatively small. If a workflow requires arbitrary graph traversal, dynamic graph modification, or independent execution ordering, Flowline is probably not the right abstraction.
One processor, one run
A FlowProcessor is intentionally one-shot:
let outcome = try await processor.run()If the same flow needs to run again, create another processor. This avoids having to reset prepared results, failures, retry attempts, pending control requests, and internal scheduling state between runs.
When I would use Flowline
I would not use Flowline for every group of asynchronous operations.
If two independent requests can be expressed clearly with async let, that is the simpler solution:
async let profile = loadProfile()
async let preferences = loadPreferences()The same applies to workloads that fit naturally into a TaskGroup.
Flowline becomes more useful when a workflow has several of these properties at once:
- some operations depend on other preparation results;
- independent preparation should start as early as possible;
- prepared results must be applied in deterministic order;
- some steps can be skipped;
- retry decisions belong to the workflow rather than the networking layer;
- the flow may need to pause, resume, or cancel;
- you want the orchestration state to stay separate from the individual services performing the work.
Application startup, session restoration, onboarding, preloading, and multi-step feature initialization are examples where this combination can appear.
The library does not replace Swift concurrency. Its job is narrower: coordinate concurrent preparation while keeping the application-facing part of the flow ordered and predictable.
Flowline is available on GitHub.
