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 theMapView; we get it back from thearcgisViewReadyChangeevent 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.
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
- Value imports from
@arcgis/corelive only in files the map chunk owns. Stores, models, types and anything on the root route import types. - New map-drawing features export from
arcgisOnDemandModule.ts. No newimport()into the SDK anywhere else. - Never return
nullfrom the request interceptor for an auth error. Abort errors only. - The SDK's logger stays intercepted. If you need its output locally, read the
console.warnthe interceptor emits outside production. - 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.