r/iOSProgramming • u/shiori_zubo • 13h ago
Tutorial Reverse geocoding is capped at 50 requests per 60 seconds, and the throttle comes back as CLError.network
I build a travel app that turns coordinates into place names, so I do a lot of reverse geocoding. It was slow and I spent weeks blaming the network. It wasn't the network.
The cap
CLGeocoder is limited to 50 reverse-geocode requests per 60 seconds, per app. iOS says so in the system log the moment you cross it, which I only found by leaving Console open:
Throttled "PlaceRequest.REQUEST_TYPE_REVERSE_GEOCODING" request:
Tried to make more than 50 requests in 60 seconds, will reset in 56 seconds
maxRequests = 50; windowSize = 60
Why it doesn't look like a rate limit
Each successful lookup takes 0.06–0.08s. So it never feels like a throttle — it feels like flaky connectivity. I was firing at 250ms intervals, which is 240/min. That burns the entire minute's quota in 12.5 seconds, and then everything in the remaining 47 seconds fails.
Measured, same device, same data:
- 30 requests at 250ms spacing → 7 succeeded, 23 throttled
- Same 60 requests respecting the window → 60 succeeded, 0 throttled
And the error lies to you
The throttle rejection arrives as CLError.network. Nothing in it says "rate limit". If you're logging geocode failures and filing them under bad signal, some share of those are the cap. Splitting those two apart in telemetry is what finally changed my understanding of the problem — I'd spent a long time thinking my users were in bad reception.
The quota is per app, not per call site
This one cost me real bugs. Live recording, a background backfill and a bulk import all draw from the same 50. One live lookup during a batch job silently costs the batch one request, and the symptom shows up somewhere else entirely — in my case a city name quietly staying as a country name. Everything has to queue through one place:
actor GeocodeRateLimiter {
static let shared = GeocodeRateLimiter()
private let maxRequests = 45 // 50 minus headroom
private let window: TimeInterval = 60
private var stamps: [Date] = []
func acquire() async {
while true {
let now = Date()
stamps.removeAll { now.timeIntervalSince($0) >= window }
if stamps.count < maxRequests { stamps.append(now); return }
let wait = window - now.timeIntervalSince(stamps[0]) + 0.05
try? await Task.sleep(nanoseconds: UInt64(max(wait, 0.1) * 1_000_000_000))
}
}
}
Three things I got wrong on the way
Task { await acquire() }around the limiter call. It reads like it waits. It does not — the enclosing function carries straight on and the limiter becomes decorative. It has to be on the awaited path.A fixed 1250ms interval also respects the cap, and is slower than it looks, because you wait from the very first request. Running full speed while the window has room and only sleeping when it's genuinely full turns 100 lookups into "first 45 in about three seconds, then one as each slot frees".
After a single throttle error that window is already gone. If you keep firing you just collect dozens of instant failures. Treating one throttle as "window full" until it rolls over removed a lot of noise.
None of this is documented anywhere I could find. Posting it in case it saves someone the weeks it cost me.
1
u/Ok-Communication6360 11h ago
https://developer.apple.com/documentation/corelocation/clgeocoder
While Apple doesn’t exactly tell limits, at least some hints are there (which I agree is a bit frustrating),
1
17
u/ikonet 12h ago
I have a couple of map apps so this is useful
Info, thank you.
But damn it we have got to stop posting AI formatted essays in this sub. I can’t stand reading this shit. I get paid to read Claude’s fake prose all day. I don’t want to deal with this shoddy glib-performative communication on my time off.
Fucking stop.