← Back to Blog

The field key and the /0 you never chose — reading feature service attributes and sublayer ids the way the service defines them

A greyed-out map app on the left shows blank pins and an empty popup, beside three failed reads - a table, a layer stack and a JSON response - each ending in a grey cross. In the center, a magnifier and shield over a field list and a layer tree, one row highlighted in each, joined by a blue line to the service icon below. On the right, a blue check and the same app working: bright pins and a filled popup.

A developer points a small map app at a public agency service to draw pins and show the facility name in a popup. The prototype was written against a layer whose field was name, and it appends /0 to the service URL. Both of those are values the developer typed. Neither is a value the developer gets to choose.

That is the whole argument of this post. The attribute key your code indexes and the sublayer id it appends are both properties of the service. A client that does not resolve them at run time reads undefined where a value should be, or, over raw REST, gets back an HTTP 200 whose JSON body carries error code 400. Over raw REST nothing throws in that second case unless your code goes looking for it. The SDK is louder about a missing layer: it rejects the load, as the measurements below show.

Everything below was measured on October 5, 2026, anonymously, mainly against two public services: an ArcGIS Online hosted service in Esri’s Living Atlas organization, and the USGS The National Map structures MapServer, which runs on ArcGIS Server 11.3. A second Living Atlas service, ArcGIS_Tapestry_2025_Packed_Circles, covers the multi-layer cases, and two smaller checks are reported where they come up: a scan of the first 400 Living Atlas services and an anonymous probe of a private service. The JavaScript results come from @arcgis/core 5.1.26 under Node and from the js.arcgis.com/5.0/ CDN build (5.0.19) in headless Chromium. Several of the behaviors below are measured and undocumented; where that is the case the text says so, and it means a later release could change them.

The job where this bites

Run the prototype against the USGS structures service and here is what happens, request by request.

The pins in that story are the developer’s scenario, not something we rendered. What we ran were the requests and the reads. Swap in a Living Atlas hosted service whose only layer is 11, Block_Centroids_2020_Located, and a hard-coded /0 gets HTTP 200 with "The requested layer (layerId: 0) was not found." in the body. Meanwhile, new FeatureLayer({ url: serviceRoot }) with no layerId loads layer 11 without complaint.

Each service answered the question it was asked. The defect this post is about is in the two values the client assumed.

A field has a name and an alias, and only the name is a key

Every field on a feature layer carries two strings. ArcGIS Online’s own help is precise about which is which: “Changing the display name for a field does not affect the field name stored in the attribute table. Because it’s a property of the field and not a field name, the display name can contain spaces and nonalphanumeric characters.” The display name is the alias. On the Living Atlas layer used here, the name is County_Name and the alias is County Name; the name P0010001 carries the alias Total Population in 2020.

The JavaScript SDK mirrors this on its Field class, with name and alias as separate properties, and it notes that for a service-backed layer “you do not need to add fields to the constructor of a FeatureLayer loaded from a service since they are already defined by the service.” That last clause is the point: the field list is the service’s, not yours.

In every query response we measured, the attribute keys were field names (on the USGS service in a different case; see below), never aliases. On the Living Atlas layer, where name and alias differ, a full outFields=* read of layer 11 returned the keys OBJECTID, GEOID, County_Name, State_Name, P0010001, Driving10, Walking10 and AccessTypes. No alias appeared as a key. The USGS layer cannot settle the question on its own, because its aliases equal its names. The popup side follows the same split: FieldInfo.fieldName is documented as “The field name as defined by the service”, and its label as “A string containing the field alias.”

Casing is whatever the source carried

There is a common shorthand that ArcGIS Online field names are uppercase. The documentation does not say that, and the measurements contradict it. The Living Atlas layer is mixed case (County_Name, GEOID, P0010001). The USGS structures layer declares uppercase (NAME, CITY). In our own build, the uppercase layers are the ones sourced from USGS The National Map, and even those carry a lowercase phone field. The ArcGIS Online publishing page says field names must meet the requirements of the database ArcGIS Online stores the data in, and must be unique; it does not say how case is derived from a shapefile, file geodatabase or CSV. So the defensible rule is the plain one: the casing is whatever the source carried, and a client should not assume any.

The request side is forgiving

Where the case mismatch does not hurt you is in the request. On both services we measured, field names in outFields and in where were matched case-insensitively:

HOSTED layer 11: outFields=county_name,geoid -> keys ['County_Name','GEOID']
HOSTED layer 11: outFields=COUNTY_NAME,GEOID -> keys ['County_Name','GEOID']
HOSTED layer 11: where=state_name='Ohio' | STATE_NAME='Ohio' | State_Name='ohio' -> 1 row, State_Name 'Ohio'
TNM layer 14:    outFields=name,city -> keys ['name','city']
TNM layer 14:    outFields=NAME,CITY -> keys ['name','city']   (layer fields[] says NAME, CITY)
TNM layer 14:    where=state='OH' -> 1 row ; where=STATE='oh' -> 0 rows

Two things deserve a second look. First, the output shows that the hosted layer also matched the string value case-insensitively ('ohio' found Ohio), and the USGS service did not ('oh' found nothing, with no error). Value comparison is a property of the backend you are talking to; do not generalize from either one. Second, a field name that does not exist in any case fails the whole query, and it fails the same quiet way as the missing layer: HTTP 200, with a body such as {"error": {"code": 400, "message": "Cannot perform query. Invalid query parameters.", "details": ["'outFields' parameter is invalid"]}}. None of the field-name tolerance is documented, so treat it as observed behavior, not a contract.

The response side is where it breaks

The failure is in client code, and it runs in opposite directions depending on which client you are.

Over raw REST, the response keys are not guaranteed to match the layer’s fields[].name byte for byte. The hosted layer returned its canonical casing (County_Name) whatever casing was requested. The USGS service declared NAME, CITY and OBJECTID in its layer resource and keyed every query response name, city and objectid, whatever case was requested, while its own displayFieldName still read NAME. That is one ArcGIS Server 11.3 service, not a rule about ArcGIS Server, and no documentation we read explains it either way. It is enough to show that looking a key up through the metadata is not sufficient over raw REST. A raw-REST client has to match keys case-insensitively.

Through the JavaScript SDK, in our test with 5.1.26 under Node and with the 5.0.19 CDN build in Chromium, queryFeatures re-keyed Graphic.attributes to the layer’s fields names. On USGS layer 14 the wire said name and the graphic said NAME, so attrs["name"] was undefined. There was one exception: with an explicit outFields: ["NAME", "CITY"], the object-id key the SDK added on its own stayed lowercase, and the keys came back as objectid, NAME, CITY. This re-keying is undocumented, it was measured against one service, and a minor release could change it. The lookup below is documented. Reading attrs[field.name] after it still leans on the re-keying, and the objectid exception shows the re-keying can miss, so a full defense also falls back to the case-insensitive key match shown in the raw-REST section.

The defense in the SDK: ask the layer for the field

The SDK documents a case-insensitive lookup for exactly this. FieldsIndex exists, in its own words, to “make case-insensitive lookups for a field by its name”, and its get() method says “The name is case-insensitive.” FeatureLayer.getField() is documented the same way. Resolve the field once, then read the attribute by the name the layer gives back, falling back to a case-insensitive key match. This is the lookup half, as we executed it in Node against @arcgis/core 5.1.26; the fallback is the getAttr helper further down:

import FeatureLayer from "@arcgis/core/layers/FeatureLayer.js";

async function readField(serviceUrl, wantedField) {
  const layer = new FeatureLayer({ url: serviceUrl }); // no layerId: SDK picks the service's first layer
  await layer.load();
  const field = layer.fieldsIndex.get(wantedField);    // documented as case-insensitive
  if (!field) throw new Error(`No field "${wantedField}" on layer ${layer.layerId}`);
  const { features } = await layer.queryFeatures({
    where: "1=1", outFields: [field.name], returnGeometry: false, num: 1,
  });
  const attrs = features[0].attributes;
  return { layerId: layer.layerId, key: field.name, label: field.alias, value: attrs[field.name], naive: attrs[wantedField] };
}

console.log(await readField(
  "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/Block_Centroids_2020_Located/FeatureServer",
  "county_name"));
console.log(await readField(
  "https://carto.nationalmap.gov/arcgis/rest/services/structures/MapServer/14",
  "name"));
{ layerId: 11, key: 'County_Name', label: 'County Name', value: 'Autauga County', naive: undefined }
{ layerId: 14, key: 'NAME', label: 'NAME', value: 'Temple University Hospital - Northeast Campus', naive: undefined }

The naive column is the bug. The value column shows the lookup working on both services in one run, but that read still relies on the re-keying described above, so for the object id, or if a later release stops re-keying, fall back to the case-insensitive match below. Note what the first line also shows: the service URL had no sublayer id at all, and the layer that loaded was 11. That sample only loads and queries; we did not draw either layer. The sample also takes the first layer on one service and a hard-coded 14 on the other, only to keep the field lesson separate from the layer-id lesson. Layer 14 is the hospitals copy under the Labels group; 49 is the copy under Features. In production, pass readField a URL you have validated against the service’s layers[] (below). We did not test the attributes on graphics returned by a click on a drawn layer.

Two related facts from the same measurements. A field that does not exist comes back empty from getField("no_such_field"), so the guard in the sample is where a typo surfaces. And the object-id key is not reliably OBJECTID: it came back objectid in the exception above, so do not write attrs.OBJECTID.

Arcade is already safe here. The Arcade type guide states “The keys of the dictionary are case insensitive”, and in our run $feature.county_name and $feature["COUNTY_NAME"] both returned “Autauga County”. A field that does not exist in any case throws (“Key not found”) rather than returning an empty value. What we did not test is a PopupTemplate whose {fieldName} substitution uses the wrong case, because that needs a rendered view. So this post makes no claim about template strings or the SDK’s own popup, only about code that reads attributes directly. If your app can use the built-in popup instead of custom attribute reads, test that path yourself; we did not.

The defense over raw REST: match the key, not the string

Without the SDK there is no re-keying to rely on, and no name in the layer metadata you can trust to equal the response key. Match case-insensitively, with an exact match first. This helper follows the same pattern as our own routing code, and is shown exactly as it ran in our raw-REST test against layer 11:

const getAttr = (attrs, name) => {
  if (Object.hasOwn(attrs, name)) return attrs[name];
  const k = Object.keys(attrs).find((x) => x.toLowerCase() === name.toLowerCase());
  return k === undefined ? undefined : attrs[k];
};

Against layer 11 it produced a["county_name"] = undefined | getAttr(a, "county_name") = Autauga County. On the USGS layer, the same helper finds lowercase name when your code asks for the metadata’s NAME.

Sublayer ids are assigned by whoever published the service

The /0 habit comes from a real default. ArcGIS Pro’s sharing help: “By default, each layer you share is automatically assigned an ID based on its drawing order in the Contents pane, beginning with 0 for the first layer.” The same page lets the author override it, and even suggests doing so in a pattern that guarantees gaps: “You should be intentional when assigning IDs, like sequentially numbering your layers in multiples of 10 (10, 20, 30, and so on).” So ids need not start at 0 and need not be contiguous. The page also warns that, unless they are pinned, “service sublayer IDs can potentially change when the web layer or service is overwritten, causing disconnects in web applications that reference the service sublayer.”

Pinning is a publisher action. It is a setting on the Map Properties dialog box in ArcGIS Pro, and Esri’s advice is to “manually assign layer IDs prior to publishing if you intend to overwrite the web layer or service in the future.” That guidance is documented for publishing from Pro; we did not find it stated for uploads made directly in ArcGIS Online. And if you consume a service someone else publishes, such as Living Atlas or USGS, you cannot pin anything. You can only discover and validate.

How often is the id not 0? Rarely, and it is worth being honest about that. In a scan of the first 400 FeatureServer and MapServer services listed in Esri’s Living Atlas organization, 3 had no layer 0: Block_Centroids_2020_Located (11), ArcGIS_Tapestry_2025_Packed_Circles (3 and 4) and Chennai_Solid_Waste_Management (1). The case for discovery is not that /0 is usually wrong. It is that the id is the service’s choice, the occasional exception fails silently over raw REST, and the USGS service shows the other trap: a layer 0 that exists and is the wrong thing to query.

What a missing sublayer looks like on the wire

Ask the Living Atlas service for a layer it does not have:

GET .../Block_Centroids_2020_Located/FeatureServer/0?f=json
HTTP/1.1 200 OK
{"error":{"code":400,"message":"","details":["The requested layer (layerId: 0) was not found."]}}

GET .../Block_Centroids_2020_Located/FeatureServer/0/query?where=1%3D1&outFields=*&f=json
HTTP 200
{"error":{"code":400,"message":"Invalid URL","details":["Invalid URL"]}}

The status is 200. The 400 lives in the JSON body (we found no documentation of this; it is measured). Code that checks response.ok sees success, and the /query form does not even mention the layer. Every raw-REST helper should parse the body and throw on error, which is what the sample below does. One more caution: a private service answered our anonymous requests with that same Invalid URL body at its root, so an anonymous probe cannot tell “no layer 0” from “not shared with you”. Do not diagnose a private service that way.

What the SDK does with no layerId

The SDK reference, as we read it against 5.1.26, disagrees with itself here, and the measurement settles which entry to trust. The layerId entry says that if no id is given, “the first layer in the service (layerId = 0) is selected.” The url entry on the same page says “If no layerId is given, then the first layer in the service will be loaded.” The second matches what happened:

A url=service, no layerId:                LOADED type=feature layerId=11
B url=service, layerId:0:                 FAILED name=request:server message="The requested layer (layerId: 0) was not found."
C url=service/0:                          FAILED (same message)
E Layer.fromArcGISServerUrl(service):     LOADED type=feature layerId=11
F Layer.fromArcGISServerUrl(service/0):   FAILED (same message)
G portalItem, no layerId:                 LOADED type=feature layerId=11
H portalItem, layerId:0:                  FAILED (same message)
Tapestry [3, 4], url, no layerId:         LOADED layerId=3
Tapestry [3, 4], portalItem, no layerId:  LOADED layerId=3

Lines A, B, C, E and F ran in Node on 5.1.26; G, H and the Tapestry portal-item line ran in Chromium on 5.0.19; the Tapestry url line ran in both. So with no layerId, the SDK loaded “the first layer in the service”, and it was not 0: 11 on a one-layer service, 3 on the two-layer Tapestry service. Whether “first” means first listed or lowest id we could not separate, because on Tapestry layer 3 is both. So do not treat leaving the id off as a shortcut: which layer the SDK treats as first is the service’s choice, not yours. Pass a layerId you have validated. Layer.fromArcGISServerUrl() on a service root is documented to resolve to a GroupLayer when the service has multiple layers, and on Tapestry it did, holding both sublayers.

Discover the id from layers[], and do it properly

The discovery call is the service resource itself, <service>?f=json, which the REST reference describes as providing “basic information about the feature service, including the feature layers and tables that it contains”. Its layers[] carries each sublayer’s id and name.

Taking layers[0] from that list repeats the /0 assumption by another route: it takes whatever comes first, and on the USGS service layers[0] is the Labels group layer. Matching on name alone fails too, because sublayer names repeat. The structures service has two sublayers called Hospitals/Medical Centers: 14 under Labels and 49 under Features. And on Tapestry, matching on geometry type alone is ambiguous, because both layers are polygons. This version skips group layers and matches the full group path. It is the sample as executed in Node, with plain fetch and no SDK:

// Discover a sublayer id from the service description. Never layers[0].
// Match on the full group path because sublayer names need not be unique.
async function findLayer(serviceUrl, { path, geometryType } = {}) {
  const r = await fetch(`${serviceUrl}?f=json`);
  const svc = await r.json();
  if (svc.error) throw new Error(`HTTP ${r.status}, body error ${svc.error.code}: ${svc.error.message}`);
  const byId = new Map((svc.layers ?? []).map((l) => [l.id, l]));
  const pathOf = (l) => {
    const parts = [l.name];
    for (let p = byId.get(l.parentLayerId); p; p = byId.get(p.parentLayerId)) parts.unshift(p.name);
    return parts.join(" > ");
  };
  const matches = (svc.layers ?? []).filter((l) =>
    l.type !== "Group Layer" && !(l.subLayerIds?.length) &&                  // skip group layers
    (!geometryType || l.geometryType === geometryType) &&
    (!path || pathOf(l).toLowerCase() === path.toLowerCase()));
  if (matches.length !== 1)
    throw new Error(`${matches.length} layers match: ${matches.map((l) => `${l.id} = ${pathOf(l)}`).join(" | ")}`);
  return `${serviceUrl}/${matches[0].id}`;
}

const TNM = "https://carto.nationalmap.gov/arcgis/rest/services/structures/MapServer";
const TAP = "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/ArcGIS_Tapestry_2025_Packed_Circles/FeatureServer";
const tryIt = async (url, args) => {
  try { console.log(JSON.stringify(args), "->", await findLayer(url, args)); }
  catch (e) { console.log(JSON.stringify(args), "-> THROWS", e.message); }
};
await tryIt(TNM, { geometryType: "esriGeometryPoint", path: "Hospitals/Medical Centers" });
await tryIt(TNM, { path: "Features > Medical & Emergency Response > Hospitals/Medical Centers" });
await tryIt(TNM, { path: "Labels > Medical & Emergency Response > Hospitals/Medical Centers" });
await tryIt(TAP, { geometryType: "esriGeometryPolygon" });
await tryIt(TAP, { path: "Tapestry_PackedCircles_LifeModes" });
{"geometryType":"esriGeometryPoint","path":"Hospitals/Medical Centers"} -> THROWS 0 layers match:
{"path":"Features > Medical & Emergency Response > Hospitals/Medical Centers"} -> https://carto.nationalmap.gov/arcgis/rest/services/structures/MapServer/49
{"path":"Labels > Medical & Emergency Response > Hospitals/Medical Centers"} -> https://carto.nationalmap.gov/arcgis/rest/services/structures/MapServer/14
{"geometryType":"esriGeometryPolygon"} -> THROWS 2 layers match: 3 = Tapestry_PackedCircles_Segments | 4 = Tapestry_PackedCircles_LifeModes
{"path":"Tapestry_PackedCircles_LifeModes"} -> https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/ArcGIS_Tapestry_2025_Packed_Circles/FeatureServer/4

The two throws are the design working. A bare leaf name is not a full path, so it matches nothing; an ambiguous geometry type matches two. In both cases the function refuses to guess, and it says what it found. Be clear about what that buys you: a group path is chosen by the same publisher as the id, so it is no more permanent. The value is that the app fails loudly when the service stops looking the way it expects. Where a publisher has pinned ids, keep the id in configuration and use a check like this one at load to confirm it still points at the layer you meant. The first version of this function matched on name alone, and it threw 2 layers match on the hospitals: that failure is why the path match exists. Discovery costs one ?f=json request per service, which you can cache; the SDK already fetches the layer resource when it loads, and fieldsIndex.get runs on the client.

Field labels: the alias is what a reader sees

The field name is the key; the alias is the label. Where a publisher never set aliases, the label is the raw name. The USGS layer’s aliases equal its field names (NAME, SOURCE_DATADESC, ADDRESSBUILDINGNAME), and the SDK sample above reported label: 'NAME' for it. A descriptive alias is the more legible label. WCAG 2.2 Success Criterion 2.4.6 states the principle: it “does not require the use of labels; however, it does require that if labels are present, they must be accurate and sufficiently clear or descriptive.” Whether a read-only popup row counts as a label in that criterion’s sense is an interpretation W3C does not settle, so take it as guidance, not as a conformance finding.

Who can fix it depends on whose service it is. In ArcGIS Online, setting display names requires that “You must be the owner of the hosted feature layer or table, an organization administrator, or a member of a shared update group to which the layer is shared.” A reader of Living Atlas or USGS data is none of those. The client-side route is open to anyone, though: set PopupTemplate.fieldInfos[].label, or set FeatureLayer.fieldConfigurations. Check the FieldConfiguration reference for which layer types support it first; our test did not check display on a map-service layer. Since 4.34 the SDK’s getFieldAlias() is described as “now the recommended approach for obtaining a field’s alias”, and it resolves the field configuration alias first, then the field’s own alias. On USGS layer 14, after setting fieldConfigurations = [{ name: "NAME", alias: "Facility name" }], getFieldAlias("NAME") returned Facility name while field.alias still read NAME. That test checked getFieldAlias() only, on a map-service layer, with the configuration set after the layer loaded; we did not check that a popup honored it. Neither route changes the service.

Scale, cost and access, briefly

Scale. Both measured layers report a maxRecordCount of 2000, and the hosted layer also reports a standardMaxRecordCount of 16000 and a tileMaxRecordCount of 8000. In our requests exceededTransferLimit came back true whenever the page was smaller than the matching set. On the hosted layer, pagination support is reported under advancedQueryCapabilities, not at the top level. Two earlier posts on this blog cover what maxRecordCount does to counts, linked below; nothing in this post changes it.

Cost. Every request in this post was anonymous: reading a public hosted layer needed no account, no key and no token. That says nothing about credits, and we found no sentence in the credits documentation on what a query costs, so we make no claim either way. What is documented is the publisher’s side: hosted feature storage is billed at “2.4 credits per 10 MB stored per month, calculated hourly”, a charge that excludes hosted feature layer views and feature attachments (attachments are charged as file storage instead). It applies to ArcGIS Online hosted layers only; the USGS service runs on ArcGIS Server and involves no ArcGIS Online credits.

Access. A private hosted layer needs an access token, and how you get one matters more than the field-name question this post is about. Esri’s API key page is direct: “API key authentication is the easiest type of authentication to use, but it is not sufficient to protect confidential information”, and for data services it marks API-key access “Supported, but not recommended due to security risks.” For confidential or sensitive data it points to stronger methods, user authentication and app authentication. Which of those fits a browser-only app is a question to settle against Esri’s authentication documentation before you ship; this post does not answer it, and an API key in client code is not the answer. Referrer URLs are recommended “in order to enhance the security of the token”; they enhance it, they do not make it safe to ship a key that unlocks confidential data in client code. Generating API keys also requires a Creator user type or higher plus the Generate API keys and Assign privileges to OAuth 2.0 applications privileges. It is not available on ArcGIS Online Trial or ArcGIS for Personal Use subscriptions, or to the Hub Community Member user type.

If you are not the one writing the code. The field-key failure looks like success: a blank line in custom popup code raises no error. The layer-id failure looks like success to any code that checks only the HTTP status, because a missing sublayer comes back as HTTP 200. An app can pass every demo against the service it was built on and break the first time it is pointed at a service published by someone else, or at one republished from ArcGIS Pro without pinned ids. The fix is a few lines and one request at load. Without it, the first sign of trouble can be a user noticing that some names are missing, with no error that points at the cause.

What our own build does

The case-insensitive half we already practice. Our routing and Places code reads attributes through the getAttr helper shown above and a string variant of it, because the layers it reads mix casings. The layer-id half we do not yet practice. One of our builds hard-codes a private service’s sublayer id in a registry instead of discovering it, and several of our builds append /FeatureServer/0 to default URLs. As far as we know those services do have a layer 0, so those URLs work today. They are undefended, not broken, and this post is the reason they will change. None of our builds uses fieldsIndex.get or getField yet, either.

The short version

References

Pointing an app at services you do not control?

The field key and the sublayer id are the two values that break first when an app meets someone else’s service, and over raw REST neither one throws when it does. This checklist is what our own builds are changing to, and we will run the same check on yours. Book a free intro call and we will look at what your app assumes about the services it reads.

Book a free intro call