Skip to main content

Loading Esri Without Paying Twice

The ArcGIS runtime is the single largest thing this app ships: roughly two thirds of all client JavaScript by compressed size. For a long time it also rode along in the first paint of every route, map or no map, because a store imported Map as a value and a hook pulled a barrel file that reached MapView. Hydration competed with a megabyte of geometry code the login page never used. This page is the set of rules that came out of fixing that, and the small amount of SDK-level configuration that runs the moment the map's chunk evaluates.

Two packages, one runtime​

We depend on two Esri packages, pinned to the same major line in package.json:

  • @arcgis/core — the ES module SDK: MapView, WebMap, FeatureLayer, StreamLayer, renderers, symbols, geometry operators, reactiveUtils, IdentityManager. This is the half the hooks and utilities program against.
  • @arcgis/map-components — the web components. We render <arcgis-map> and slot <arcgis-legend> and <arcgis-distance-measurement-2d> inside it. The map element creates the MapView; we get it back from the arcgisViewReadyChange event and from then on talk to the core SDK directly.

Two small files make the pairing work. arcgis-map-components.d.ts at the repo root references the components' React typings and the core interfaces.d.ts, so JSX accepts the custom elements and the global __esri namespace resolves; tsconfig.json lists it under include. And both next.config.mjs (transpilePackages) and jest.config.js (transformIgnorePatterns) name @arcgis/core explicitly, because the package ships modern syntax that neither Next nor Jest transforms by default.

Where the runtime is allowed to load​

The map itself is FeatureMap, and it is mounted exactly once by MapPortalProvider through next/dynamic with ssr: false. That dynamic import is the front door for the ArcGIS runtime: the MapView chunk and the calcite UI components load when the map mounts, not before.

Everything reachable from the root route must therefore avoid value imports from @arcgis/core. The pattern you will see repeated is deliberate:

// stores/mapStore.ts — a type-only import keeps the runtime out of every route's bundle
import type EsriMap from '@arcgis/core/Map';
import type MapView from '@arcgis/core/views/MapView';

useFeatureMapAGOLLayers (which usePageUpdates runs on every page) imports only types. util/map/webMapLayerCatalog.ts goes further and types its inputs structurally — a CatalogLayer is "anything with title, type, visible, parent, layers" — so it never needs the SDK at all. data/layers.ts keeps its popup templates as plain autocastable objects rather than new PopupTemplate(...), because that file is reachable from the navbar.

The single lazy boundary​

Several features draw on the map without owning it: the alert side panel, locate-on-map's sightings layer, the what3words square, the PeakFinder companion marks. Each needs ArcGIS classes at runtime. The obvious design, a dynamic() per feature, is the one we had to back out.

Turbopack's production chunker enumerates every path through the application's dynamic imports, and the ArcGIS graph contains hundreds of internal lazy loads of its own. Each separate import() that reaches into it multiplies that analysis. One pull request that added two such sites took the production build from 5.9 GB to 12.9 GB peak memory and killed the 7 GB CI agent at 95%.

So there is one door:

// components/organisms/ArcGISOnDemand/arcgisOnDemand.ts
export const loadArcgisOnDemand = () => import('./arcgisOnDemandModule');

// arcgisOnDemandModule.ts re-exports AlertMessage, CompanionMapMarks,
// SightingsMapLayer and W3WMapLayer — statically imported ONLY through the line above.

A host component calls loadArcgisOnDemand() and renders the export it needs. Adding a new map-drawing feature means adding an export to that module, never a new import() site that touches @arcgis/core. This rule is the kind that looks pedantic until the build agent dies.

Gotcha: the PR pipeline never builds the image

Pull-request validation runs install and unit tests only. A bundle or build-memory regression surfaces on the development build after merge. Measure locally with /usr/bin/time -l on a production build before you touch import boundaries, and compare against a base worktree rather than an absolute number: Turbopack's peak scales with core count, so a 12-core laptop reports higher than the 2-core agent.

What runs when the map chunk evaluates​

FeatureMap.tsx has a short module-level preamble that executes once, before any component renders. Each line is there for a production incident.

The ResizeObserver polyfill is imported before any ArcGIS module, because the SDK touches it at evaluation time and some mobile WebViews lacked it. The theme CSS is imported from the package (@arcgis/core/assets/esri/themes/light/main.css) and bundled, replacing a render-blocking stylesheet link to js.arcgis.com that also pinned a different SDK version than the one installed. esriConfig.request.trustedServers is cleared so IdentityManager stays in charge of credentials.

Then two interceptors. The first keeps the SDK's own diagnostics out of App Insights:

esriConfig.log.interceptors.push((level, module, ...args) => {
if (process.env.NODE_ENV !== 'production') {
console.warn(`[${module}] (${level})`, ...args);
}
return true; // handled: the SDK's console.error writer never runs
});

Before this existed, "Failed to load tile" and "Failed to create layerview" were about 87% of all browser exceptions in production, because lib/applicationInsights.ts overrides console.error and tracks every call. Returning true tells the SDK the message was handled.

The second is a request interceptor with two rules and one important non-rule. Abort errors are swallowed: tiles are cancelled constantly during a pan, and each cancellation is not a fault. Authentication errors are logged and rethrown. The SDK's normal flow for a secured layer is an unauthenticated metadata probe, a 499 "Token Required", then a retry with the registered credential; returning null from the interceptor breaks that chain and leaves the layer permanently unauthenticated. That bug has been shipped once. Do not fix the 499 you see in the network tab by catching it.

Warming the connections​

app/RootLayoutClient.tsx emits preconnect hints for www.arcgis.com, js.arcgis.com, basemaps.arcgis.com, services.arcgisonline.com, server.arcgisonline.com and services8.arcgis.com. The map's first requests fan out across all of them, and a TLS handshake per host before the token arrives is a few hundred milliseconds saved on a cold load. They cost nothing on routes that never mount a map, which is why they live in the layout.

The rules​

  1. Value imports from @arcgis/core live only in files the map chunk owns. Stores, models, types and anything on the root route import types.
  2. New map-drawing features export from arcgisOnDemandModule.ts. No new import() into the SDK anywhere else.
  3. Never return null from the request interceptor for an auth error. Abort errors only.
  4. The SDK's logger stays intercepted. If you need its output locally, read the console.warn the interceptor emits outside production.
  5. Measure build memory before merging an import-boundary change, against a base worktree.

Wrapping up​

The SDK is two packages and one front door. The root route sees only types; the map chunk owns the runtime; secondary features share a single lazy module because Turbopack charges per path. Four lines of esriConfig keep Esri's noise out of telemetry and keep the token retry chain intact.

Next: The Token Handshake, the credential that makes any of those requests succeed.