Skip to main content

The Request Budget

On 12 August 2026 the production map went from a few dozen ArcGIS 429 responses a day to nine thousand. Nothing had been deployed to the GIS side. A hover handler that issued one feature query per hovered site had met a busy incident day, and Esri's hosted services answered the way rate-limited services do. Over the following weeks the same investigation found a second unbounded query, a startup that loaded a hundred layers nobody had turned on, and a megabyte of SDK in the first paint of the login page. This page is the budget that came out of it: three costs, the rules each one imposes, and how to measure them.

Cost one: requests to Esri services​

Hosted feature services on ArcGIS Online are rate-limited per organisation, the limit is not published, and a 429 does not surface as a 429 in our telemetry. The SDK swallows the HTTP response and throws its own error; the status code exists only as JSON inside the exception message. Within the 30 days around that incident there were about 18,000 of them, all from browsers, and half were against the camera feature service.

The rules that keep us under the limit:

  • Hit-test graphics carry their attributes. The camera service loads with outFields: ['*'], so hover and tap read cId off the hit and the fallback feature query never runs. That one change removed the largest source.
  • Geometry questions are answered from memory. Which cameras are in an area, which cameras look into it, which are in the viewport under Velocity: all computed from staticCameraStore with the SDK's client-side geometry operators, never with a server query.
  • Load layers when asked. The parking lot took startup from roughly 110 ArcGIS requests to about 15 on the UAT map (115 layers catalogued, 88 parked). Portal-item lookups for the layers that are on, and the 1 MB WebMap JSON, remain, and are the portal's to trim.
  • Bound bursts. Boundary-layer priming loads metadata four at a time; a layer that failed is never retried by a toggle, because the SDK's load() is one-shot anyway.
  • Query with intent. The one remaining where: '1=1' plus outFields: ['*'] query is the Alerts panel reading the whole Alerts layer when it opens. It is bounded by the number of alerts, which is small, but it is the pattern to avoid for anything fleet-sized.
  • Refresh on a schedule you chose. The camera service refreshes every 60 s; Velocity companions re-query once a minute unless the WebMap set an interval. Some portal layers (the aircraft feed, for one) carry their own refresh cadence in the WebMap, and the only lever for those is the portal.

Cost two: work per frame​

The map replaced a GraphicsLayer plus a scale watcher with feature layers and declarative renderers, and the measured difference at 5,000 points was an order of magnitude: idle CPU from 5–10% to under 1%, pan from 30–45 to 60 frames a second, memory roughly halved. The deep dive Why FeatureMap has the table. The rules that preserve it:

  1. Symbology is attribute-driven. Never set a symbol on a feature; change the attribute or the renderer.
  2. Zoom costs no JavaScript. Size and visibility are visual variables and an Arcade expression; the only zoom listeners are reactiveUtils.when(stationary) redraws of offset geometry.
  3. Edit only what changed. Two diffs (store reference, then a microdiff of PTZ snapshots with a 60 s timestamp grace) select a handful of cameras per tick, debounced 100 ms, and cameras hidden by scale are skipped before geometry is computed.
  4. One edit per change, and never delete-and-add to recolour. The refresh path deletes and adds in a single applyEdits; the FOV/LOS chain serialises every mutation.
  5. Hit-test a short list. Every pointer-move handler passes include.
  6. Don't subscribe to the fleet. Hooks read the real-time store with getState() at event time; subscribing to the Map re-created every handler on every poll and re-registered the hover, tap and extent listeners each time.
  7. Keep the cursor line on a graphics layer. Ten moves a second on a one-feature feature layer was a query, an edit, a re-index and a layer-view refresh per move.

Memory is the quieter cost. A hidden stream still holds its features and its socket, which is why parking removes it; and the per-session heap growth operators reported in September 2026 turned out to be the PeakFinder engines, not the map, which is worth remembering before blaming ArcGIS for a slow tab.

Cost three: bytes​

ArcGIS is about two thirds of all client JavaScript. The audit on 2026-09-05 found the core runtime in the initial HTML of every route; the type-only import pass took the initial payload from 1,214 KB to 564 KB brotli. The counter-cost is Turbopack build memory, which rises with every dynamic import path into the SDK, hence the single lazy boundary. The rules are on the loading page; the measurement is a production build timed with /usr/bin/time -l against a base worktree, never an absolute number.

Timing and scale knobs​

KnobValueLives in
Arrow update debounce100 msuseFeatureMapPolling.ts
Timestamp-only diff grace60,000 mscameraChangeDetector.ts
Arrow visibility scale threshold3,000,000layerFactories.ts
Extent query / persist debounce200 / 500 msuseFeatureMapExtent.ts
Camera service refresh60 sFeatureMap.tsx
Hover include list2 layersuseFeatureMapHover.ts
Layer-view wait bound8 sarea filter, camera layers hook
Velocity layer load bound10 suseFeatureMapCameraLayers.ts
Handoff re-apply coalesce300 msuseFeatureMapCameraLayers.ts
Boundary priming concurrency4areaBoundaryLayers.ts
Mobile arrow build batch / yield200 / 10 msuseFeatureMapCameraLayers.ts

What the telemetry should show after a change​

Before merging anything that touches the map's network behaviour, check three things on a development build with the browser's network panel filtered to arcgis: the request count at startup (tens, not a hundred), that hovering sites issues no query requests, and that panning issues tile requests and nothing else. After deploy, the Kusto query above should stay flat. "Nothing in the console" is not evidence; the SDK's logger is intercepted on purpose, and its output only appears as console.warn outside production.

Wrapping up​

Three costs: requests, which a rate limit turns into incidents; frames, which the attribute-driven design keeps cheap as long as nobody reintroduces per-feature symbols or per-hover queries; and bytes, which one lazy boundary keeps out of first paint at the price of build memory. Every rule above is attached to a measured incident, which is the best reason to keep it.

Next: Working Without Esri Watching.