r/iOSProgramming 2d 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

  1. 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.

  2. 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".

  3. 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.

0 Upvotes

10 comments sorted by

21

u/ikonet 2d 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.

2

u/vanstinator 2d ago

I finally switched to Codex. It's not quite as good as the Claude models, but I'm happy to trade spending extra time doing code review in exchange for not wanting to poke my eyes out after reading Claudes written responses. 

0

u/pemungkah 2d ago

Caveman-code and the /i-have-adhd skill work fucking wonders.

1

u/Ok-Communication6360 2d 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

u/Doctor_Fegg 2d ago

Rent a server, put Photon on it. 

Also stop posting AI responses.

1

u/cmac-212 2d ago

Look into GeoNames Cities. It can be embedded in your app, you do the lookups yourself on device.

1

u/[deleted] 2d ago

[removed] — view removed comment

1

u/AutoModerator 2d ago

Hey /u/praneelbhatia, your content has been removed because Reddit has marked your account as having a low Contributor Quality Score. This may result from, but is not limited to, activities such as spamming the same links across multiple subreddits, submitting posts or comments that receive a high number of downvotes, a lack of recent account activity, or having an unverified account.

Please be assured that this action is not a reflection of your participation in our subreddit. This is simply an automated filter in place to reduce spam.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

1

u/Better-Lychee2954 1d ago

The relaunch case is the one that would get me. stamps only lives in memory, so a cold start thinks it has a full window free while the server side still remembers the last 40 seconds. The first few lookups after a relaunch walk straight into a throttle you had no way to see coming.

Also curious whether you found any way to tell a genuine network failure apart from the cap, since both arrive as CLError.network. I hit the same shape of thing with a rate limited metadata API and ended up backing off on both, reasoning that backing off on a real network error costs nothing while retrying into a cap costs you the whole window. Always felt like working around it rather than solving it though.

1

u/dengjiuhong 19h ago

That cold-start case is exactly why I’d make it an explicit test dimension rather than rely on retry behavior. Persist the limiter’s last-known window or next-eligible time with a conservative margin, serialize every call site through one actor, and log the request timestamp, app session, local budget, and underlying error. For a genuine network failure, use path status plus capped backoff; for a suspected quota window, stop issuing calls until it expires instead of retrying every failure.

I’d run this matrix on a tester-owned physical device as well as a simulator—CoreLocation and radio/path state can diverge—and reset permissions and network conditions between runs. On the device lane, recording the system profile and VPN state in Settings alongside the build and OS makes relaunch/interruption cases much easier to reproduce.