Rate-Limiting Yourself Before Google Does It For You
Pacer.kt exists for one reason: keep this app's request pattern inside the envelope a real person browsing photos.google.com would produce. Jittered gaps, capped batches, and an exponential cooldown that only decays on success.
The realistic failure mode for an app that drives Google's private batchexecute API isn't a scary legal letter — it's an HTTP 429. Google's observed response to bulk-deletion tools in this space has been rate-limiting and temporary blocks, not account-level action. The entire job of Pacer.kt is to make sure this app never gets close to that threshold in the first place.
The main defence against the realistic failure mode is never tripping the threshold, not recovering gracefully once you have.
Rule 1: jittered minimum gaps
A person scrolling their photo timeline in a browser doesn't fire requests at a perfectly even interval — network jitter, rendering time, and human scrolling speed all vary. A script that fires exactly every 350ms, forever, is a much easier automated-traffic signal to spot than one that doesn't.
const val MIN_READ_GAP_MS = 350L
const val MIN_WRITE_GAP_MS = 1_200L
private suspend fun pace(minGapMs: Long) {
val jitter = Random.nextLong(-minGapMs / 4, minGapMs / 4 + 1)
val target = (minGapMs + jitter).coerceAtLeast(100L)
...
}Reads (timeline pages while scrolling) get a 350ms floor; writes (mutations) get a wider 1,200ms floor with the same ±25% jitter, because mutations are rarer and more conspicuous in a real user's traffic pattern than reads are.
Rule 2: batch caps that match the web UI
const val MAX_TRASH_BATCH = 50
const val BATCH_REST_MS = 2_500LThe web client itself sends trash operations in batches of this rough order when you select many photos and delete them at once. Sending a batch far larger than what the real UI would ever produce is, again, the single most obvious automation signal available — nobody manually selects 5,000 photos in one gesture. Capping at 50 and resting 2.5 seconds (plus jitter) between batches keeps every observable batch inside what a human session could have produced.
Rule 3: an exponential cooldown that only decays on success
private fun cooldownMs(): Long =
if (consecutiveFailures == 0) 0L
else (2_000L * (1L shl (consecutiveFailures - 1).coerceAtMost(5)))
.coerceAtMost(60_000L)
suspend fun onRateLimited() = mutex.withLock { consecutiveFailures++ }
suspend fun onSuccess() = mutex.withLock {
if (consecutiveFailures > 0) consecutiveFailures--
}The first 429 doubles the pause added to every subsequent call, capped at one minute. Crucially, that penalty doesn't reset to zero the instant one call succeeds — onSuccess() only decrements the failure counter by one. A single lucky request right after a 429 doesn't erase the caution; it takes a run of sustained successes to fully back off. That asymmetry matters: the failure mode you're avoiding (a longer block or an account flag) is much worse than the cost of staying cautious for a few extra seconds.
What this buys, and its limit
None of this makes the app's traffic indistinguishable from a human — a sustained, unattended run of thousands of deletions over hours is still, in aggregate, not something a person manually clicking through a UI would produce. What Pacer buys is staying inside the envelope Google appears to tolerate today, based on the observed behavior of similar tools with tens of thousands of users and no documented account bans. It's a calculated, disclosed risk, not a guarantee — which is exactly how the README frames it, and exactly why the app recommends a Google Takeout export before any bulk delete regardless of how careful the pacing is.
Series: Google Photos Cleaner.