Last updated: 2026-06-19
A Kotlin Multiplatform library for Android and iOS that provides a production‑ready polling engine behind a fluent API that reads like a sentence:
// "Poll the status until it's COMPLETED, every 2 seconds."
Polling.poll { api.checkStatus() }
.until { it == "COMPLETED" }
.every(2.seconds)
.start(scope)You describe the poll, then end with a verb that picks how it runs. Features:
- Exponential backoff and jitter (
.backoff { … }) or a fixed cadence (.every(2.seconds)) - Timeouts (
.timeout,.timeoutPerAttempt) and attempt caps (.atMost), bounded or unbounded - Four run models chosen by the terminal verb: converge (
.start/.await), continuous stream (.collect/.asFlow), and multiplexed shared sessions (.shared) - A live
PollHandlefrom.start()forpause/resume/cancel/retune— no id lookups - Observability hooks (
.onAttempt/.onResult/.onComplete) and domain‑level results
flowchart TD
A[Start] --> B{Attempt fetch}
B -->|Success & meets success predicate| C[Outcome: Success]
B -->|stopWhen predicate true| I[Stop: Exhausted / stream completes]
B -->|Failure & retryable| D[Backoff delay]
B -->|Failure & not retryable| E[Outcome: Exhausted]
B -->|Timeout reached| F[Outcome: Timeout]
D --> G{More attempts left?}
G -->|Yes| B
G -->|No / unlimited| E
%% External control
B -. pause/resume/cancel/retune .-> H[PollHandle]
Modules:
- /pollingengine — library code
- /composeApp — sample shared UI (Compose Multiplatform)
- /iosApp — iOS app entry (SwiftUI)
- Project Overview
- The Fluent API in 60 seconds
- Core Concepts
- Public API Reference
- Installation and Dependency
- Android Implementation
- Migration from 0.2.x
- iOS (Swift) Implementation
- Backoff & Retry Reference
- Setup/Build Instructions
- Publishing & Versioning
- Contributing
- License
PollingEngine helps you repeatedly call a function until a condition is met or limits are reached, or to continuously observe a remote value over time. It is designed for long‑polling workflows like waiting for a server job to complete, checking payment/compliance status, or streaming a live list.
Platforms: Kotlin Multiplatform (common code) with Android and iOS targets.
API entry point: everything starts at the
Pollingfacade object — callPolling.poll { … }(orPolling.pollResult { … }) and chain from there. The internal engine and config types are not part of the public surface.
Every poll is built the same way: Polling.poll { fetch }, optional refinements, then a
terminal verb that decides what you get back.
import `in`.androidplay.pollingengine.polling.Polling
import `in`.androidplay.pollingengine.polling.dsl.Retry
import kotlin.time.Duration.Companion.seconds
// Converge → a controllable handle
val handle = Polling.poll { api.checkStatus() } // suspend () -> T ; just throw on error
.until { it == "COMPLETED" } // terminal success condition
.every(2.seconds) // cadence (or .backoff { … } for exponential)
.retryWhen(Retry.networkOrServer) // optional
.start(scope) // ← verb: launch + return PollHandle
handle.pause(); handle.resume(); handle.cancel() // control, no id lookups| Terminal verb | You get | Use it for |
|---|---|---|
.start(scope) |
PollHandle<T> (control + outcomes flow) |
fire‑and‑control a converging poll |
.await() |
PollingOutcome<T> (suspends) |
a one‑shot poll inside a coroutine |
.collect(scope) { v -> } |
PollHandle<T> |
react to every successful value |
.asFlow() |
Flow<T> |
a cold stream for Compose collectAsState |
.shared(key) |
SharedPoll<T> |
one loop fanned out to many subscribers |
Refinements (all optional, sane defaults): .until · .stopWhen · .every · .backoff { } ·
.atMost · .timeout · .timeoutPerAttempt · .retryWhen · .mapErrors · .on(dispatcher) ·
.onAttempt · .onResult · .onComplete · .keepAliveFor · .replayLast.
Coming from 0.2.x? See the Migration from 0.2.x map below.
Your fetch — the most common form is Polling.poll { … }, where the lambda returns a plain
value (T) and throws on error; the engine wraps it and runs throwables through your retry
policy. If you need to say "no value yet, keep polling" without a value, use
Polling.pollResult { … } and return a PollingResult<T>:
PollingResult variant |
Meaning |
|---|---|
PollingResult.Success(data) |
A value was retrieved; checked against .until / .stopWhen. |
PollingResult.Failure(error) |
Failed this tick; retried if .retryWhen(error) is true. |
PollingResult.Waiting |
Not ready yet; keep polling. |
PollingResult.Cancelled / PollingResult.Unknown |
Cancelled / indeterminate state. |
PollingOutcome<T> — the terminal result of a converge run (.start / .await /
Polling.sequence):
| Variant | Fields |
|---|---|
PollingOutcome.Success |
value, attempts, elapsedMs |
PollingOutcome.Exhausted |
last, attempts, elapsedMs (also used when .stopWhen fires) |
PollingOutcome.Timeout |
last, attempts, elapsedMs |
PollingOutcome.Cancelled |
attempts, elapsedMs |
Run models (chosen by the terminal verb):
- Converge —
.start(scope)returns aPollHandlewhoseoutcomesflow emits onePollingOutcome;.await()suspends and returns it directly. Polls until terminal success, exhaustion, timeout, or cancellation. - Observe —
.collect(scope) { value -> }(callback) or.asFlow()(coldFlow<T>) emit the value of every successful tick and keep running until a stop condition (.until,.stopWhen, a non‑retryable failure, or limits). - Shared —
.shared(key)returns aSharedPoll<T>: one loop per key fans each tick value out to allstream()subscribers with a single fetch per tick.
The Polling facade:
| Member | Signature | Notes |
|---|---|---|
poll |
{ suspend () -> T } -> PollBuilder<T> |
Start a poll; fetch returns a value. |
pollResult |
{ suspend () -> PollingResult<T> } -> PollBuilder<T> |
Advanced: full result vocabulary. |
sequence |
suspend (vararg PollBuilder<T>) -> PollingOutcome<T> |
Run polls in order, stop at first non‑success. |
cancelAll |
suspend () |
Cancel every active poll. |
shutdown |
suspend () |
Stop the engine; no new polls after. |
activeCount |
Int |
Number of active polls (diagnostics). |
activeIds |
suspend () -> List<String> |
Ids of active polls (diagnostics). |
PollBuilder<T> — refinements .until · .stopWhen · .stopWhenResult · .every · .backoff { }
· .atMost · .timeout · .timeoutPerAttempt · .retryWhen · .mapErrors · .on · .onAttempt
· .onResult · .onComplete · .keepAliveFor · .replayLast; terminal verbs .start(scope) ·
.await() · .collect(scope) { } · .asFlow() · .shared(key).
PollHandle<T>: val id, val outcomes: Flow<PollingOutcome<T>>, val isActive, val isPaused,
suspend pause()/resume()/cancel(), retune { … }.
SharedPoll<T>: val key, fun stream(): Flow<T>, fun stream(filter: (T) -> Boolean): Flow<T>.
Retry presets (for .retryWhen): Retry.always, Retry.never, Retry.networkOrServer.
Coordinates on Maven Central:
- groupId:
in.androidplay - artifactId:
pollingengine - version:
1.0.0
Gradle Kotlin DSL (Android/shared):
repositories { mavenCentral() }
dependencies { implementation("in.androidplay:pollingengine:1.0.0") }Gradle Groovy DSL:
repositories { mavenCentral() }
dependencies { implementation "in.androidplay:pollingengine:1.0.0" }Maven:
<dependency>
<groupId>in.androidplay</groupId>
<artifactId>pollingengine</artifactId>
<version>1.0.0</version>
</dependency>The iOS framework is published with baseName PollingEngine (import PollingEngine in Swift).
- CocoaPods (from this repository during development):
# Podfile (example)
platform :ios, '14.0'
use_frameworks!
target 'YourApp' do
pod 'pollingengine', :path => '../pollingengine'
endThen:
./gradlew :pollingengine:generateDummyFramework
cd iosApp && pod install- Swift Package Manager: If you publish an XCFramework, add the package URL and version in Xcode. (SPM publication is not configured in this repo out‑of‑the‑box.)
On Android you typically drive polling from a ViewModel and expose state via StateFlow. Three
patterns cover most needs.
Describe the poll and end with .start(viewModelScope). You get a PollHandle back immediately —
collect its outcomes for the result, and use it directly to pause/resume/cancel.
import `in`.androidplay.pollingengine.polling.Polling
import `in`.androidplay.pollingengine.polling.PollingOutcome
import `in`.androidplay.pollingengine.polling.dsl.PollHandle
import `in`.androidplay.pollingengine.polling.dsl.Retry
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Duration.Companion.milliseconds
class JobStatusViewModel(
private val api: JobApi,
private val jobId: String,
) : ViewModel() {
private val _uiState = MutableStateFlow<JobUiState>(JobUiState.Idle)
val uiState: StateFlow<JobUiState> = _uiState.asStateFlow()
private var handle: PollHandle<String>? = null
fun startPolling() {
if (handle != null) return
_uiState.value = JobUiState.Loading
val poll = Polling.poll { api.checkStatus(jobId) } // just throw on error
.until { it.equals("COMPLETED", ignoreCase = true) }
.retryWhen(Retry.networkOrServer)
.backoff {
initialDelay = 1.seconds
maxDelay = 10.seconds
multiplier = 1.5
}
.atMost(10)
.timeout(2.minutes)
.onAttempt { attempt, delayMs -> Log.d("Poll", "attempt #$attempt, next in $delayMs ms") }
.start(viewModelScope)
handle = poll
viewModelScope.launch {
poll.outcomes.collect { outcome ->
_uiState.value = when (outcome) {
is PollingOutcome.Success -> JobUiState.Success(outcome.value)
is PollingOutcome.Exhausted -> JobUiState.Error("Exhausted after ${outcome.attempts} attempts")
is PollingOutcome.Timeout -> JobUiState.Error("Timed out after ${outcome.elapsedMs} ms")
is PollingOutcome.Cancelled -> JobUiState.Idle
}
handle = null
}
}
}
fun pause() = viewModelScope.launch { handle?.pause() }
fun resume() = viewModelScope.launch { handle?.resume() }
fun cancelPolling() {
viewModelScope.launch { handle?.cancel() }
handle = null
}
}
sealed interface JobUiState {
data object Idle : JobUiState
data object Loading : JobUiState
data class Success(val data: String) : JobUiState
data class Error(val message: String) : JobUiState
}For a one‑shot poll inside a coroutine, drop the handle entirely and use .await():
val outcome = Polling.poll { api.checkStatus(jobId) }
.until { it == "COMPLETED" }
.every(2.seconds)
.await().collect(scope) { } reacts to every successful tick and stops when .stopWhen (or a
terminal/limit) fires. Pair it with .every(…) for a steady cadence.
Polling.poll { api.currentQueuePosition() }
.every(2.seconds) // one tick / 2s, forever
.stopWhen { it == 0 } // stop when we reach the front
.collect(viewModelScope) { position ->
_uiState.update { it.copy(queuePosition = position) }
}For Compose, .asFlow() gives a cold Flow<T> you can collectAsState:
val position by remember {
Polling.poll { api.currentQueuePosition() }.every(2.seconds).asFlow()
}.collectAsState(initial = null).shared(key) de‑duplicates by key: a single fetch per tick is fanned out to every stream()
collector. Polling starts on the first subscriber and stops keepAliveFor after the last leaves.
val session = Polling.poll { repository.getServicesList(vin) }
.every(10.seconds) // one tick / 10s, forever
.stopWhen { it.isEmpty() } // stop when the list drains
.keepAliveFor(15.seconds) // keep alive 15s after last subscriber leaves
.replayLast(1) // late subscribers get the last value
.shared(key = vin)
// Two independent views fed by the SAME 10s network call:
viewModelScope.launch {
session.stream { services -> services.any { it.isActive } }
.collect { active -> _activeState.value = active }
}
viewModelScope.launch {
session.stream { services -> services.any { it.needsAssociation } }
.collect { assoc -> _assocState.value = assoc }
}.start() / .collect() return a PollHandle — control the poll directly, no id lookups:
handle.pause()
handle.resume()
handle.retune { initialDelay = 5.seconds; maxDelay = 5.seconds } // hot‑swap backoff
handle.cancel()
// Polling.cancelAll() / Polling.shutdown() for global controlLifecycle: the poll stops automatically when the
scopeyou pass to.start()/.collect()is cancelled (e.g.viewModelScope), so leaks are avoided without manual teardown.
The builder/PollingConfig API is gone; everything now flows from Polling.poll { … }.
| 0.2.x | 1.0.0 |
|---|---|
Polling.startPolling { fetch=…; isTerminalSuccess=…; backoff=BackoffPolicy(…) }.launchIn(scope) |
Polling.poll { … }.until { … }.backoff { … }.start(scope) |
Polling.run(config) |
Polling.poll { … }.until { … }.await() |
Polling.observe { … }.collect { … } |
Polling.poll { … }.every(d).collect(scope) { … } (or .asFlow()) |
Polling.shared(key) { …; stopTimeoutMs=…; replay=… } |
Polling.poll { … }.every(d).keepAliveFor(d).replayLast(n).shared(key) |
Polling.compose(a, b) |
Polling.sequence(a, b) |
fetch = { PollingResult.Success(api()) } |
Polling.poll { api() } (plain value; throw on error) |
isTerminalSuccess = { … } |
.until { … } |
shouldRetryOnError = RetryPredicates.networkOrServerOrTimeout |
.retryWhen(Retry.networkOrServer) |
backoff = BackoffPolicies.fixed(2_000) |
.every(2.seconds) |
backoff = BackoffPolicy(initialDelayMs=…, …) |
.backoff { initialDelay = …; … } / .atMost / .timeout |
listActiveIds() + pause(id)/updateBackoff(id,…) |
val h = ….start(scope); h.pause() / h.retune { … } |
SharedPollingSession<T> |
SharedPoll<T> |
Expose Kotlin Flows to Swift through a thin helper in your shared module, then bind to SwiftUI.
Import the framework as PollingEngine.
// shared module, e.g. IosPollingHelper.kt
import `in`.androidplay.pollingengine.polling.Polling
import `in`.androidplay.pollingengine.polling.PollingOutcome
import `in`.androidplay.pollingengine.polling.dsl.PollHandle
import `in`.androidplay.pollingengine.polling.dsl.Retry
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlin.time.Duration.Companion.seconds
object IosPollingHelper {
private val scope = CoroutineScope(Dispatchers.Main)
/** Converge: drives a job to completion and reports a single outcome. */
fun startStatusPolling(
fetch: suspend () -> String,
onUpdate: (Int) -> Unit,
onComplete: (PollingOutcome<String>) -> Unit,
): PollHandle<String> = Polling.poll { fetch() }
.until { it.equals("COMPLETED", ignoreCase = true) }
.every(2.seconds)
.retryWhen(Retry.networkOrServer)
.onAttempt { attempt, _ -> onUpdate(attempt) }
.start(scope)
.also { handle -> handle.outcomes.onEach { onComplete(it) }.launchIn(scope) }
/** Observe: a continuous stream of every successful value. */
fun observeQueue(
fetch: suspend () -> Int,
onValue: (Int) -> Unit,
): PollHandle<Int> = Polling.poll { fetch() }
.every(2.seconds)
.stopWhen { it == 0 }
.collect(scope) { onValue(it) }
}The helper returns a
PollHandle— callhandle.cancel()from Swift to stop the poll (noJobbookkeeping or id lookups needed).
import SwiftUI
import PollingEngine // KMP framework baseName
@MainActor
final class PollingViewModel: ObservableObject {
@Published var status: String = "Idle"
private var poll: PollHandle?
func start() {
status = "Polling…"
poll = IosPollingHelper.shared.startStatusPolling(
fetch: { try await MyApi.shared.checkStatus() },
onUpdate: { [weak self] attempt in self?.status = "Attempt \(attempt)" },
onComplete: { [weak self] outcome in
switch outcome {
case let success as PollingOutcome.Success<NSString>:
self?.status = "Success: \(success.value)"
case let exhausted as PollingOutcome.Exhausted:
self?.status = "Exhausted after \(exhausted.attempts) attempts"
case is PollingOutcome.Timeout:
self?.status = "Timed out"
case is PollingOutcome.Cancelled:
self?.status = "Cancelled"
default:
break
}
}
)
}
func cancel() {
poll?.cancel(completionHandler: { _, _ in })
poll = nil
status = "Idle"
}
}struct ContentView: View {
@StateObject private var viewModel = PollingViewModel()
var body: some View {
VStack(spacing: 16) {
Text(viewModel.status)
Button("Start Polling") { viewModel.start() }
Button("Cancel") { viewModel.cancel() }
}
.padding()
}
}Tip:
.shared(key),.await(), andPollHandle.pause/resume/cancelaresuspendmembers exposed to Swift as completion‑handler /asyncfunctions. Wrap them in helper functions in the shared module (as above) to keep call sites clean, and collect Flows there rather than in Swift.
Cadence. Pick one:
.every(2.seconds)— constant cadence (no growth, no jitter); unbounded by default — the natural fit for observe / shared..backoff { … }— exponential with jitter. All knobs areDuration/Doublewith safe defaults:
.backoff {
initialDelay = 500.milliseconds // delay before the 2nd attempt; grows by multiplier
maxDelay = 30.seconds // ceiling the delay is clamped to
multiplier = 2.0 // growth factor each round (>= 1.0)
jitter = 0.2 // [0.0, 1.0]; 0 disables jitter
maxAttempts = 8 // null = unlimited
overallTimeout = 120.seconds // null = no overall timeout
perAttemptTimeout = null // null disables; must be > 0 when set
}Limits (override the cadence's caps, read more clearly):
.atMost(10)— cap the number of attempts..timeout(2.minutes)— cap the overall wall‑clock time..timeoutPerAttempt(10.seconds)— cap each attempt (a slower fetch becomes a retryable timeout).
Validation rejects negative values, maxDelay < initialDelay, and multiplier < 1.0.
Retry (.retryWhen(…), with Retry presets):
Retry.networkOrServer— retry network/server/timeout/unknown errors (recommended default).Retry.always/Retry.never.
Error mapping (.mapErrors { throwable -> Error }) translates a thrown exception into a domain
Error for .retryWhen. The default maps any throwable to Error(-1, message), which
Retry.networkOrServer treats as retryable.
Clone and build:
git clone https://github.com/bosankus/PollingEngine.git
cd PollingEngine
./gradlew buildRun tests (all targets where applicable):
./gradlew :pollingengine:allTestsAndroid app:
./gradlew :composeApp:installDebugiOS builds (macOS):
- Open
iosAppin Xcode and run on a simulator. - If CoreSimulator issues arise, run
scripts/fix-ios-simulator.shthen retry.
Publishing to Maven Central uses com.vanniktech.maven.publish.
- Required environment variables/Gradle properties (typically set in CI):
OSSRH_USERNAME,OSSRH_PASSWORDSIGNING_KEY(Base64 GPG private key),SIGNING_PASSWORDGROUP:in.androidplay(already configured)
- Commands:
./gradlew :pollingengine:publishToMavenLocal
./gradlew :pollingengine:publish --no-configuration-cacheVersioning policy: Semantic Versioning (MAJOR.MINOR.PATCH). Public API stability is guarded by
Kotlin Binary Compatibility Validator; run ./gradlew apiCheck when modifying public APIs.
Release notes: maintain CHANGELOG.md for each version. Tag releases on Git and reference them in release notes.
We welcome contributions!
- Fork the repo and create a feature branch
- Follow Kotlin style and ktlint; run
./gradlew ktlintCheck detekt - Ensure tests pass:
./gradlew build - Open a Pull Request describing your changes
Guidelines and policies:
- CONTRIBUTING.md
- CODE_OF_CONDUCT.md
- Binary compatibility: explicit API mode and API checks are enabled; please run
./gradlew apiCheckwhen modifying public APIs.
Licensed under the Apache License, Version 2.0. See LICENSE.
Copyright (c) 2025 AndroidPlay
- Maintainer: @bosankus
- Issues: use GitHub Issues
- Security: see SECURITY.md