Throttler
July 7, 2026 ยท View on GitHub
Icon credits: Lorc, Delapouite & contributors
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
dependencies: [
.package(url: "https://github.com/boraseoksoon/Throttler.git", .upToNextMajor(from: "3.0.1"))
]
In Xcode, add this package URL:
https://github.com/boraseoksoon/Throttler.git
Quick Start
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.
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.
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.
.defaultdelays each call; a newer pending call replaces the older pending call..runFirstruns 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.
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.
.defaultdrops suppressed calls..ensureLastschedules 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.
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.
await sleep(.milliseconds(250))
execute(with:on:operation:) returns a task that optionally waits, then runs the
operation through the selected ActorType.
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.
let task = `repeat`(every: .seconds(5), times: 3, by: .mainActor) {
await refreshStatus()
}
task.cancel()
Behavior contract:
times: nilrepeats until the returned task is cancelled.times: 3runs exactly three successful iterations unless cancelled or an iteration throws.startingImmediately: trueruns once right away, then waits before future iterations.startingImmediately: falsewaits 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
timesvalue completes without running. - If an iteration throws,
onErroris called and the repeat loop stops. CancellationErrorstops the loop without callingonError.
Timeout
timeout bounds async work with a maximum duration.
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,
timeoutreturns the operation value. - If the operation throws first,
timeoutthrows the operation error. - If the deadline wins,
timeoutcancels the operation child task and throwsTimeoutError.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.
let user = try await time("fetch user") {
try await api.fetchUser()
}
Default compact output:
[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:
try await time("fetch user", style: .verbose) {
try await api.fetchUser()
}
[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:
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
@Sendableclosures. - 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. timedoes not change actor context.
Retry
retry reruns async work until it succeeds or the attempt limit is reached.
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.
CancellationErroris 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
@MainActorsync closures. - This keeps the 3.0 Sendable contract while preserving ergonomic UI-call-site usage.
3.0.0
- Removed the legacy
ActorType.currentActoralias. 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
debounceandthrottlecalls can omitidentifier, matching the sync API. - Default async identifiers are derived from the call site.
- Explicit
identifierasync calls keep the same source shape as 2.2.4.
2.2.4
- Default sync
debounceandthrottleidentifiers moved fromThread.callStackSymbolsto call-site magic-literal parameters. - The internal debounce/throttle scheduling state was unified.
- Debounce and throttle state remain independent for the same identifier.
ActorTypedocs were corrected for actor execution behavior.- Stale Linux XCTest manifest files were removed.
2.2.3
- Added
time. - Added
TimeReportStyle.compactand.verbose. - Added custom
reportdestinations fortime. - 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
delayoverload that returnsTask<Void, Never>. - Added
sleep(_:)andexecute(with:on:). - Added
.ownedActorand.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
Pull requests are welcome.
License
Throttler is released under the MIT license. See LICENSE for details.