SharedTaskStore is a Swift package for deduplicating in-flight async work so concurrent callers can reuse the same Task instead of starting duplicate work.
It provides one store for a single shared task and one store for keyed task sharing. Both are actor-based and designed for modern Swift concurrency.
In async code, it is common for multiple callers to request the same work at nearly the same time:
- multiple views ask for the same resource
- a refresh button is tapped repeatedly
- several tasks request the same user profile by identifier
Without coordination, each caller can create a new Task, which wastes network, CPU, battery, and backend capacity. SharedTaskStore makes concurrent callers await the same in-flight work.
Use SharedTaskStore when there is only one logical unit of work to share at a time.
Examples:
- bootstrapping app configuration
- refreshing the current session
- loading a single dashboard payload
Use KeyedSharedTaskStore when work should be deduplicated per identifier while still allowing different identifiers to run independently.
Examples:
- loading user profiles by user ID
- downloading images by URL
- fetching product details by product SKU
Add SharedTaskStore to your Package.swift dependencies:
dependencies: [
.package(url: "https://github.com/your-name/SharedTaskStore.git", from: "0.1.0")
]Then add the product to your target:
.target(
name: "FeatureModule",
dependencies: [
.product(name: "SharedTaskStore", package: "SharedTaskStore")
]
)import Foundation
import SharedTaskStore
struct SessionPayload: Decodable, Sendable {
let userID: String
let featureFlags: [String]
}
enum NetworkError: Error, Sendable {
case transport
case decoding
}
actor SessionService {
private let store = SharedTaskStore<SessionPayload>()
func loadSession() async throws -> SessionPayload {
try await store.executeTask {
Task {
let url = URL(string: "https://example.com/api/session")!
let data: Data
do {
(data, _) = try await URLSession.shared.data(from: url)
} catch {
throw NetworkError.transport
}
do {
return try JSONDecoder().decode(SessionPayload.self, from: data)
} catch {
throw NetworkError.decoding
}
}
}
}
}If several callers invoke loadSession() while the same task is already running, they all await the shared in-flight task.
import Foundation
import SharedTaskStore
struct UserProfile: Decodable, Sendable {
let id: String
let displayName: String
}
enum UserProfileError: Error, Sendable {
case transport
case decoding
}
actor UserProfileService {
private let store = KeyedSharedTaskStore<String, UserProfile>()
func profile(for userID: String) async throws -> UserProfile {
try await store.executeTask(for: userID) {
Task {
let url = URL(string: "https://example.com/api/users/\(userID)")!
let data: Data
do {
(data, _) = try await URLSession.shared.data(from: url)
} catch {
throw UserProfileError.transport
}
do {
return try JSONDecoder().decode(UserProfile.self, from: data)
} catch {
throw UserProfileError.decoding
}
}
}
}
}Concurrent requests for the same userID share work. Requests for different userID values can proceed independently.
KeyedSharedTaskStore can optionally limit how many newly created keyed tasks run at once.
import Foundation
import SharedTaskStore
actor ImagePrefetcher {
private let store = KeyedSharedTaskStore<URL, Data>(concurrentTaskLimit: 4)
func data(for url: URL) async throws -> Data {
try await store.executeTask(for: url) {
Task {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
}
}Important detail:
- the limit only applies when creating a new task
- callers that join an already-running task for the same key do not consume another permit
The stores do not transform task behavior.
- If the underlying
Taskthrows, callers receive that error. - If the task is cancelled, callers observe normal Swift task cancellation behavior.
- A completed task, whether successful or failed, is removed from the store.
waitForCurrentTask()onSharedTaskStoreintentionally ignores any thrown error because it is meant for best-effort synchronization.
Both stores are actors, so reads and writes to internal state are serialized by Swift concurrency.
- concurrent callers cannot race while inserting or clearing the shared task reference
- keyed tasks are managed per identifier within actor isolation
- the optional concurrency limit uses an internal actor-based
AsyncSemaphore
AsyncSemaphore is intentionally internal in this package. It exists only to support KeyedSharedTaskStore without expanding the public API surface beyond task-deduplication primitives.
public actor SharedTaskStore<Success: Sendable> {
public init()
public func task(
orCreate createTask: @Sendable () -> Task<Success, any Error>
) -> Task<Success, any Error>
public func executeTask(
orCreate createTask: @Sendable () -> Task<Success, any Error>
) async throws -> Success
public func waitForCurrentTask() async
public func clear()
}
public actor KeyedSharedTaskStore<Key: Hashable & Sendable, Success: Sendable> {
public init(concurrentTaskLimit: Int? = nil)
public func task(
for key: Key,
orCreate createTask: @Sendable () -> Task<Success, any Error>
) -> Task<Success, any Error>
public func executeTask(
for key: Key,
orCreate createTask: @Sendable () -> Task<Success, any Error>
) async throws -> Success
public func clear(task: Task<Success, any Error>, for key: Key)
}The package includes XCTest coverage for:
- reuse of a single in-flight task under concurrency
- cleanup after successful and failed completion
- best-effort waiting without error propagation
- task sharing per key
- independence across different keys
- protection against clearing a newer task with an older completion
- enforcement of
concurrentTaskLimit
Run the test suite with:
swift testThis package is released under the MIT License. See LICENSE.