Icon credits: [Lorc, Delapouite & contributors](https://game-icons.net)
# Throttler
Throttler is a small Swift concurrency package for scheduling and measuring work:
`debounce`, `throttle`, `delay`, `` `repeat` ``, `timeout`, `time`, and `retry`.
It is useful when you want direct closure-based helpers instead of building a
reactive pipeline for simple timing behavior.
## Requirements
- Swift 5.9+
- iOS 16.0+
- macOS 13.0+
- watchOS 9.0+
- tvOS 16.0+
## Installation
```swift
dependencies: [
.package(url: "https://github.com/boraseoksoon/Throttler.git", .upToNextMajor(from: "3.0.1"))
]
```
In Xcode, add this package URL:
```text
https://github.com/boraseoksoon/Throttler.git
```
## Quick Start
```swift
import Throttler
debounce {
print("runs after 1 second without another call from this call site")
}
throttle {
print("runs immediately, then suppresses calls from this call site for 1 second")
}
delay(.seconds(2)) {
print("runs after 2 seconds")
}
`repeat`(every: .seconds(1), times: 3) {
await refreshStatus()
}
Task {
let value = try await timeout(.seconds(3)) {
try await fetchValue()
}
let measured = try await time("fetch") {
try await fetchValue()
}
let retried = try await retry(3, every: .milliseconds(300)) {
try await fetchValue()
}
}
```
## Identifiers
`debounce` and `throttle` group calls by `identifier`.
When `identifier` is omitted, the package derives one from the call site
(`fileID:line:column`). Repeated calls from the same source location share one
group. Calls from different source locations are separate.
Use an explicit identifier when calls from different locations should share one
group, or when grouping should depend on a dynamic value.
```swift
debounce(.milliseconds(300), identifier: "search") {
performSearch()
}
throttle(.seconds(1), identifier: "row:\(rowID)") {
refreshRow(rowID)
}
```
The default identifier path avoids stack-symbol capture. It still performs the
normal work needed to build and compare the call-site identifier.
## ActorType
The scheduling helpers that accept `by actor: ActorType` use that value to run
the scheduled closure.
- `.mainActor`: sync closures run on the main actor. Async closures are invoked
from the main actor, but normal Swift actor isolation still applies inside the
async body.
- `.ownedActor`: closures run through a package-owned serial actor.
- `.taskContext`: closures run directly in the scheduled task context.
Explicitly `@MainActor` sync closures are accepted by dedicated overloads for
`debounce`, `throttle`, `delay`, and `execute`.
The package's scheduling state is actor-backed. Shared mutable state inside your
operation still needs the right actor context or normal Swift synchronization.
## Debounce
`debounce` runs only the latest call in a burst.
```swift
for value in 1...5 {
debounce(.milliseconds(300), identifier: "input") {
print(value)
}
}
// Later, after the debounce window, prints:
// 5
```
Behavior contract:
- The default duration is 1 second.
- Calls with the same resolved identifier share one debounce group.
- `.default` delays each call; a newer pending call replaces the older pending
call.
- `.runFirst` runs the first eligible call immediately, then debounces later
calls in the same window.
- A non-positive duration runs immediately.
- An operation that has already started is not cancelled by a newer call.
- The sync overload is fire-and-forget.
- The async throwing overload returns the task that submits the scheduling
request. Thrown errors are delivered to `onError`.
## Throttle
`throttle` runs the first eligible call immediately and suppresses later calls
until the window has elapsed.
```swift
for value in 1...5 {
throttle(.seconds(1), identifier: "save") {
print(value)
}
}
// In a tight burst with the default option, prints only the first eligible call.
```
Behavior contract:
- The default duration is 1 second.
- Calls with the same resolved identifier share one throttle group.
- `.default` drops suppressed calls.
- `.ensureLast` schedules the latest suppressed call to run after the remaining
throttle window.
- A non-positive duration runs immediately.
- The sync overload is fire-and-forget.
- The async throwing overload returns the task that submits the scheduling
request. Thrown errors are delivered to `onError`.
## Delay
`delay` runs an operation after a duration.
```swift
delay(.seconds(2)) {
print("runs after 2 seconds")
}
let task = delay(.seconds(2), by: .taskContext) {
await refreshStatus()
}
task.cancel()
```
Behavior contract:
- The default duration is 1 second.
- A non-positive duration runs immediately.
- The sync overload is fire-and-forget.
- The async throwing overload returns the delay task. Cancelling it before the
operation starts prevents the operation from running.
- Thrown errors from the async overload are delivered to `onError`.
## Sleep and Execute
`sleep(_:)` waits for a positive duration and returns early when the task is
cancelled. Non-positive durations return immediately.
```swift
await sleep(.milliseconds(250))
```
`execute(with:on:operation:)` returns a task that optionally waits, then runs the
operation through the selected `ActorType`.
```swift
let task = execute(with: .milliseconds(250), on: .taskContext) {
await refreshStatus()
}
```
Cancelling the returned task before the operation starts prevents the operation
from running.
## Repeat
`` `repeat` `` runs async work on a serial cadence. Because `repeat` is a Swift
keyword, call it with backticks.
```swift
let task = `repeat`(every: .seconds(5), times: 3, by: .mainActor) {
await refreshStatus()
}
task.cancel()
```
Behavior contract:
- `times: nil` repeats until the returned task is cancelled.
- `times: 3` runs exactly three successful iterations unless cancelled or an
iteration throws.
- `startingImmediately: true` runs once right away, then waits before future
iterations.
- `startingImmediately: false` waits first, then runs.
- Iterations never overlap. The next wait starts after the current operation
finishes.
- A non-positive interval completes without running.
- A non-positive `times` value completes without running.
- If an iteration throws, `onError` is called and the repeat loop stops.
- `CancellationError` stops the loop without calling `onError`.
## Timeout
`timeout` bounds async work with a maximum duration.
```swift
do {
let response = try await timeout(.seconds(3)) {
try await api.fetch()
}
print(response)
} catch TimeoutError.timedOut(let duration) {
print("timed out after \(duration)")
}
```
Behavior contract:
- If the operation finishes first, `timeout` returns the operation value.
- If the operation throws first, `timeout` throws the operation error.
- If the deadline wins, `timeout` cancels the operation child task and throws
`TimeoutError.timedOut(duration)` after structured child-task cleanup
completes.
- A non-positive duration throws `TimeoutError.timedOut(duration)` immediately.
- Swift task cancellation is cooperative, so blocking or cancellation-ignoring
operations can delay cleanup.
## Time
`time` measures work, reports the elapsed duration, and returns or throws exactly
as the operation does.
```swift
let user = try await time("fetch user") {
try await api.fetchUser()
}
```
Default compact output:
```text
[Throttler] fetch user completed in 124.3 ms
[Throttler] fetch user failed in 2.100 s: NetworkError.timeout
[Throttler] completed in 8.7 ms
```
Verbose output:
```swift
try await time("fetch user", style: .verbose) {
try await api.fetchUser()
}
```
```text
[Throttler] label="fetch user" result=success duration="124.3 ms"
[Throttler] label="fetch user" result=failure duration="2.100 s" error="NetworkError.timeout"
```
Custom reporting destination:
```swift
try await time("fetch user", report: { logger.info("\($0)") }) {
try await api.fetchUser()
}
```
Behavior contract:
- Sync and async overloads are available.
- Sync and async overloads use `@Sendable` closures.
- The operation starts immediately.
- The operation return value is returned unchanged, including `Void`.
- The original operation error is rethrown unchanged.
- Duration is reported after success and after failure.
- Measurement uses `ContinuousClock`, not wall-clock time.
- `time` does not change actor context.
## Retry
`retry` reruns async work until it succeeds or the attempt limit is reached.
```swift
let user = try await retry(3, every: .milliseconds(300)) {
try await api.fetchUser()
}
```
Behavior contract:
- The first attempt runs immediately.
- `retry(3, every: .milliseconds(300))` means 3 total attempts.
- The delay happens only after a failed attempt when another attempt remains.
- The first successful attempt returns immediately.
- If every attempt fails, the last operation error is thrown.
- `CancellationError` is not retried.
- Parent-task cancellation during the operation or delay throws cancellation.
- Attempts never overlap.
- The operation closure and returned value are `Sendable`.
- A non-positive delay retries immediately.
- A non-positive attempt count throws `RetryError.invalidAttemptCount(maxAttempts)`.
## Version Notes
### 3.0.1
- Added dedicated overloads for explicitly `@MainActor` sync closures.
- This keeps the 3.0 Sendable contract while preserving ergonomic UI-call-site
usage.
### 3.0.0
- Removed the legacy `ActorType.currentActor` alias. Use `.taskContext`.
- Removed the internal unchecked synchronous-operation wrapper.
- Sync scheduling closures are now `@Sendable`.
- The call-site identifier sentinel is no longer public API.
### 2.2.6
- Rewrote the README around current behavior contracts.
- Removed stale examples, stale installation version text, and broad thread-safety
claims that were easy to misread.
### 2.2.5
- Async `debounce` and `throttle` calls can omit `identifier`, matching the sync
API.
- Default async identifiers are derived from the call site.
- Explicit `identifier` async calls keep the same source shape as 2.2.4.
### 2.2.4
- Default sync `debounce` and `throttle` identifiers moved from
`Thread.callStackSymbols` to call-site magic-literal parameters.
- The internal debounce/throttle scheduling state was unified.
- Debounce and throttle state remain independent for the same identifier.
- `ActorType` docs were corrected for actor execution behavior.
- Stale Linux XCTest manifest files were removed.
### 2.2.3
- Added `time`.
- Added `TimeReportStyle.compact` and `.verbose`.
- Added custom `report` destinations for `time`.
- Added `retry(_:every:operation:)`.
- Added `RetryError.invalidAttemptCount(Int)`.
### 2.2.2
- Added `` `repeat`(every:times:startingImmediately:by:onError:operation:) ``.
- Added `timeout(_:operation:)`.
- Added `TimeoutError.timedOut(Duration)`.
### 2.2.1
- Reworked debounce/throttle/delay internals around a monotonic-clock actor.
- Fixed duplicate trailing execution for `debounce(.runFirst)`.
- Added generation checks so older scheduled debounce work cannot clear newer
scheduled work.
- Added the async/throws `delay` overload that returns `Task`.
- Added `sleep(_:)` and `execute(with:on:)`.
- Added `.ownedActor` and `.taskContext`.
### 2.0.0
- Raised platform requirements to iOS 16.0, macOS 13.0, watchOS 9.0, and
tvOS 16.0.
- Removed the older struct-based API in favor of the closure helper functions.
## Contact
boraseoksoon@gmail.com
Pull requests are welcome.
## License
Throttler is released under the MIT license. See LICENSE for details.