For a Mac app that must finish local async calculations in submission order, use a synchronous main-actor submission method that chains each task behind its predecessor. Actor isolation alone does not provide that ordering guarantee.

This walkthrough builds a small queue for synthetic integer calculations. It defines exactly when submission happens, preserves order across suspension points, returns each calculation’s result, and lets later jobs continue after an earlier job fails.

What does strict submission order mean?

Suppose a desktop calculator submits jobs A, B, and C. The required contract is: A’s calculation finishes before B’s calculation begins, and B finishes before C begins. Waiting for three independently started tasks in alphabetical order would only order the observations of their results.

The submission boundary matters. In this implementation, submission occurs when the main-actor-isolated submit method executes and installs the next link in the chain. Consecutive synchronous calls from one main-actor context establish A, B, C order without suspending between them.

If independent tasks request submission concurrently, their arrival order is unspecified. The queue preserves the order in which its submission method accepts them. If your application requires a particular order among those producers, establish that order before submitting—for example, by collecting a finite batch into an explicitly ordered array.

The design uses the main actor for the short bookkeeping step. Apple defines MainActor as a singleton actor whose executor is equivalent to the main dispatch queue. The actual calculation below belongs to a separate worker actor. MainActor documentation

Why aren’t an actor or a serial operation queue enough?

An actor executes actor-isolated work one piece at a time, but an async method can suspend and allow another call to proceed. Swift’s actor design also explicitly states that tasks waiting for an actor are not guaranteed to run in the order they originally awaited it. Consequently, putting an entire async calculation inside an actor does not by itself establish finish-before-next-start behavior. SE-0306: Actors

Creating separate Task instances also supplies no dependency between them. Tasks can begin running after creation; retaining their handles lets you await or cancel them, but does not establish an execution sequence. Task documentation

Foundation offers a different ordering mechanism. OperationQueue schedules according to readiness, priority, and dependencies; Apple advises using dependencies when a specific execution order matters. Setting maxConcurrentOperationCount to 1 limits concurrency, but the ordering requirement still calls for explicit dependencies. OperationQueue documentation

Choose the mechanism according to what your application already needs:

Situation Suitable approach What establishes order?
One known batch in one async function A loop that awaits each calculation before continuing Sequential control flow
Jobs arrive over time from desktop actions A synchronous submission method with a task chain Each calculation awaits its predecessor’s completion gate
Existing Foundation operations Dependencies configured before enqueueing Each operation depends on the previous operation’s finish state

The task chain is useful here because submission returns immediately with a handle while calculations run later. For a fixed batch with no arriving jobs, the sequential loop is usually less code.

Build a queue that waits for complete async calculations

The queue stores a private, nonthrowing completion task called tail. Each submission captures the current tail, creates a calculation task that waits for it, and replaces the tail with a new task that observes the calculation’s completion.

The completion task awaits result, which represents either success or failure after a task completes. That allows an unsuccessful calculation to release the next job without discarding the error from the calculation’s own handle. Callers can still retrieve that error through the handle. Task result documentation

The following is a complete command-line demonstration for a Mac with a Swift 6 toolchain. Save it as OrderedDemo.swift. The source was inspected but could not be compiled in this research environment because no Swift compiler was available.

import Foundation

@MainActor
final class OrderedIntQueue {
    private var tail: Task<Void, Never>?

    func submit(
        _ operation: @escaping @Sendable () async throws -> Int
    ) -> Task<Int, Error> {
        let predecessor = tail

        let job = Task<Int, Error> {
            if let predecessor {
                await predecessor.value
            }
            try Task.checkCancellation()
            return try await operation()
        }

        tail = Task<Void, Never> {
            _ = await job.result
        }

        return job
    }
}

enum CalculationError: Error {
    case inputOutOfRange
}

actor CalculationWorker {
    private var events: [String] = []

    func square(_ input: Int, label: String) async throws -> Int {
        events.append("start \(label)")
        guard (-1000...1000).contains(input) else {
            throw CalculationError.inputOutOfRange
        }

        await Task.yield()
        try Task.checkCancellation()

        let answer = input * input
        events.append("finish \(label)")
        return answer
    }

    func trace() -> [String] {
        events
    }
}

@main
struct OrderedDemo {
    @MainActor
    static func main() async {
        let queue = OrderedIntQueue()
        let worker = CalculationWorker()

        let a = queue.submit {
            try await worker.square(3, label: "A")
        }
        let b = queue.submit {
            try await worker.square(4, label: "B")
        }
        let c = queue.submit {
            try await worker.square(5, label: "C")
        }

        var answers: [Int] = []
        for job in [a, b, c] {
            switch await job.result {
            case .success(let answer):
                answers.append(answer)
            case .failure(let error):
                print("Calculation failed: \(error)")
            }
        }

        let events = await worker.trace()
        precondition(answers == [9, 16, 25])
        precondition(events == [
            "start A", "finish A",
            "start B", "finish B",
            "start C", "finish C"
        ])

        print(events.joined(separator: "\n"))
        print("Answers: \(answers)")
    }
}

Compile and run the saved program from its directory in Terminal:

swiftc -swift-version 6 -parse-as-library OrderedDemo.swift -o OrderedDemo
./OrderedDemo

The expected output follows from the example’s inputs and dependency chain:

start A
finish A
start B
finish B
start C
finish C
Answers: [9, 16, 25]

The assertions check both returned values and calculation boundaries. Merely printing results while awaiting handles in submission order would not establish that calculations ran in that order.

The worker deliberately calls Task.yield() so the calculation includes an opportunity to suspend. Yielding allows other tasks to execute, although the executor may immediately resume the same task. The queue’s ordering therefore comes from its dependencies, not from an assumption that yielding will switch tasks. Task yield documentation

Why does the chain preserve order across suspension?

Three details establish the guarantee. First, submit contains no suspension point, so another main-actor submission cannot interleave while it captures and replaces tail. Swift documents that actor-isolated code without suspension points runs without another actor task interrupting that sequence. Concurrency: Actors

Second, each job awaits its captured predecessor before invoking the calculation closure. Accessing a nonthrowing task’s value waits for that task to complete. The later job can execute its waiting bookkeeping early, but cannot begin its calculation early. Nonthrowing Task value documentation

Third, the predecessor gate completes only after the earlier job produces its result or error. Following the links backward establishes the sequence for every accepted submission: C waits for B’s completion gate, which waits for B, which waits for A’s completion gate.

This is an ordering guarantee for the work awaited inside each submitted closure. If a closure launches another unstructured task and returns without awaiting it, that additional work can outlive the closure. Include every required calculation step in the awaited operation before returning.

For desktop integration, keep one queue instance in the main-actor controller or model that handles the relevant actions. Call submit directly from that context, then use the returned handle to observe completion. Creating an independent task first and submitting only after it begins would move the submission boundary to that task’s eventual execution.

How should errors and cancellation behave?

This implementation follows a continue-after-failure policy. A failed job retains its error, its completion gate finishes, and the next accepted job becomes eligible to calculate. The demonstration inspects every handle’s result rather than abandoning the remaining results after the first error.

For a simple failure check, change B’s input from 4 to 1001. The worker rejects it before multiplication. Update the expected answers to [9, 25] and the expected trace to start A, finish A, start B, start C, finish C. The program should print the failure and still calculate C. The input bound is an illustrative contract chosen to keep this arithmetic small.

Cancellation is cooperative. The queue checks cancellation after waiting for its predecessor, and the worker checks again after yielding. Task.checkCancellation() throws CancellationError when cancellation has been requested. Task checkCancellation documentation

To check cancellation before execution, call b.cancel() immediately after submitting C and before the result loop. With these uninterrupted main-actor submissions, B cannot have entered its calculation yet. Adjust the expected answers to [9, 25] and remove both B entries from the expected trace. B still waits for A’s completion gate before reporting cancellation; C then proceeds.

Keep the completion gates private. They represent the chain’s bookkeeping, while the returned handles represent individual calculations. Canceling a job does not mean that a preceding calculation should stop or that later calculations should start while it is still running.

If your application instead requires stopping the whole batch after one failure, that needs an explicit batch policy. Changing the gate to propagate errors would change which later jobs execute and how their outcomes must be reported.

When should you use Foundation dependencies instead?

If the desktop application already represents work as Foundation Operation objects, establish a dependency from each operation to its predecessor before enqueueing. Foundation considers the dependency satisfied when the predecessor finishes, whether it succeeded or failed; success-dependent behavior needs separate outcome handling. Operation documentation

Take care when bridging Swift async functions into that model. A synchronous operation that starts a Task and immediately returns does not represent the task’s full lifetime. A custom asynchronous operation must accurately manage its execution and finish state, including the required notifications, so dependent operations wait for the intended work. Apple documents those responsibilities under asynchronous operations and finish-state reporting. Operation lifecycle documentation

For the local async calculator developed here, the task chain keeps the contract visible: accept submissions synchronously, await the predecessor’s completion, run the complete calculation, and expose its outcome. Preserve those boundaries when connecting the queue to desktop actions, and validate execution with start-and-finish traces as well as returned values.

Sources