We’ve covered task groups, but there’s another one in the family: Discard Task Groups. Simply said, it’s a task group that discards the results of its child tasks. Let’s dive in!

What is a Discarding Task Group?

Unlike regular task groups, a discarding task group does not allow a return value for its child tasks. Its discarding behavior is designed for efficient releasing of memory used by child tasks, as they’re not retained for future next() calls.

In other words, regular task groups need to store the results for each child task until they’re consumed. If you’re not interested in these results, it’s more efficient to use a discarding task group.

While child tasks can’t return a result, you can return a value for the group itself. An example implementation could look as follows:

let numberOfTasksAdded = await withDiscardingTaskGroup(returning: Int.self) { group in
    group.addTask {
        /// Perform some long running task without a return value.
        print("Long running task completed")
    }

    /// While child tasks can't return a value, the group can.
    /// For example, you could return how many tasks you've added to the group.
    return 1
}
print("The discarding task group had \(numberOfTasksAdded) task(s) added.")

A discarding task group waits for all its child tasks to complete before returning. This also applies to cancelled tasks—as long as they’re not completed, the task group won’t return. A discarding task group is always empty after completion.

The discarding task group will automatically await all of its child tasks. There’s no need—no way—to explicitly await the completion of child-tasks using methods like next().

Cancellation behavior

A discarding task group is a structured concurrency primitive, which means that cancellation automatically propagates recursively through all child tasks. A discarding task group becomes cancelled when the surrounding Task running the group is cancelled or when cancelAll() is invoked directly.

When should I use a discarding task group?

You should use a discarding task group when you need to run multiple asynchronous tasks concurrently and don’t care about their return values. This makes it ideal for fire-and-forget operations where you want to await the completion of all tasks without collecting individual results.

For example, when logging, preloading data, or performing side effects that don’t influence control flow. A discarding task group ensures proper lifecycle management and automatically propagates cancellation to all child tasks, giving you predictable behavior and safety without the overhead of handling results.

Handling errors

Like all task groups, there’s also a throwing variant:

do {
    try await withThrowingDiscardingTaskGroup { group in
        group.addTask {
            throw Error.exampleError
        }
    }
} catch {
    print("Discarding task group failed with error: \(error)")
}

A throwing discarding task group implicitly cancels itself whenever any of its child tasks throws. Note that tasks that already started won’t be affected. Since we can’t make use of next(), there’s no way to capture an error thrown and re-throw it explicitly. Instead, the discarding task group will throw the first error it captures to the outer body.

do {
    try await withThrowingDiscardingTaskGroup { group in
        group.addTask {
            /// The first thrown error will be the error thrown to the outer task.
            throw Error.exampleErrorOne
        }
        group.addTask {
            try await Task.sleep(for: .seconds(1))
            print("About to throw error two") // This line will never be reached.
            throw Error.exampleErrorTwo
        }
    }
} catch {
    /// This will print `Error.exampleErrorOne`
    print("Discarding task group failed with error: \(error)")
}

Any other tasks in the group still running will be implicitly cancelled. That’s why we will never reach the print statement in the second task.

Ignoring harmless errors

In case you want to prevent the whole group from being cancelled by a specific thrown error, you can catch this one specifically and return instead:

/// An example of a child task that returns instead of re-throwing.
try await withThrowingDiscardingTaskGroup { group in
    group.addTask {
        do {
            try someNetworkCall()
        } catch URLError.notConnectedToInternet {
            /// You can return instead of throwing the error to the group,
            /// preventing a cancellation of all group's child tasks.
            print("Ingoring notConnectedToInternet network error")
            return
        }
    }
}

A real-world example: monitoring multiple notifications

The NotificationCenter API allows to monitor a single notification with an AsyncSequence:

for await _ in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
    refreshData()
}

However, what if you want to monitor multiple notifications that should trigger a refresh? These would be long-running operations, potentially for the lifetime of the app’s session. We’re also not interested in the specific posted notifications. In other words, a perfect candidate for a discarding task group.

You can do this by adding a child task for each notification that you want to observe:

await withDiscardingTaskGroup { group in
    group.addTask {
        for await _ in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
            refreshData()
        }
    }

    group.addTask {
        for await _ in NotificationCenter.default.notifications(named: .userDidLogin) {
            refreshData()
        }
    }
}

However, it would be much cleaner to have this available as an extension on NotificationCenter. We can do this by combining an AsyncStream and a discarding task group:

extension NotificationCenter {
    func notifications(named names: [Notification.Name]) -> AsyncStream<()> {
        AsyncStream { continuation in
            /// Create a task so we can cancel it on termination of the stream.
            let task = Task {
                /// Start the discarding task group.
                await withDiscardingTaskGroup { group in
                    /// Iterate over all names and add a child task to observe the notifications.
                    for name in names {
                        group.addTask {
                            for await _ in self.notifications(named: name) {
                                /// Yield to the stream to tell the observer one of the notifications was called.
                                continuation.yield(())
                            }
                        }
                    }
                }
                continuation.finish()
            }

            continuation.onTermination = { @Sendable _ in
                task.cancel()
            }
        }
    }
}

This extension now allows us to observe as many notifications as we want by simply passing them into the notifications array:

for await _ in NotificationCenter.default.notifications(named: [.userDidLogin, UIApplication.didBecomeActiveNotification]) {
    refreshData()
}

Summary

Discarding task groups are a great alternative to regular task groups when you’re not interested in the returned values for child tasks. They’re memory efficient and an ideal candidate for when you need to run multiple asynchronous tasks concurrently in a fire-and-forget scenario.

In the next lesson, we’re going to look into the differences between structured and unstructured tasks.