← Back to Blog

definitionExpression, filter, featureEffect — and the feature count you can actually quote

A parcel table and database on the left feed a funnel into three map panels of a parcel grid, first thinned at the source, then bounded by a dashed selection box, then dimmed so only the matches read at full strength, with a row below of three check-marked tokens and one crossed-out result card, ending in a verified summary card on the right

Every serious map application eventually grows a filter. A zoning code, a date range, a district, a status. In the ArcGIS Maps SDK for JavaScript there are three properties that will all appear to narrow the layer, and the samples that demonstrate them look almost interchangeable. They are not interchangeable. They act at different points in the pipeline, so the choice between them decides what the map costs to draw and whether the number in your results panel is a fact or a guess. One of the three is also routinely mistaken for an access control, which is a third reason to be precise about which is which. This is the working reference for choosing.

Three levers, one decision

Start from what each property changes. Not what it looks like on screen — all three can produce a map showing only C-2 parcels — but what it changes about the data underneath.

PropertyLives onChangesReach for it when
definitionExpression The layer What this browser ever receives The restriction is permanent for this app, or the dataset is too big to bring down
filter The layer view What draws, out of what the layer view already holds The user is toggling criteria and you want the map to respond without a round trip
featureEffect The layer or the layer view How it draws. Nothing is removed Removing the non-matches would destroy the context that makes the matches readable
// 1. Changes what this browser ever receives.
layer.definitionExpression = "zoning = 'C-2'";

// 2. Changes what draws, out of what the layer view already holds.
layerView.filter = new FeatureFilter({ where: "zoning = 'C-2'" });

// 3. Changes how it draws. The non-matches are still there.
layerView.featureEffect = new FeatureEffect({
  filter: new FeatureFilter({ where: "zoning = 'C-2'" }),
  excludedEffect: "grayscale(100%) opacity(30%)"
});

Both support classes date from 4.22, though layer view filtering itself is older — FeatureLayerView.filter has been there since 4.11.

import FeatureFilter from "@arcgis/core/layers/support/FeatureFilter.js";
import FeatureEffect from "@arcgis/core/layers/support/FeatureEffect.js";

definitionExpression changes what reaches the browser

The querying and filtering guide draws the line that matters, and it is a line about the layer, not about the property. Layers split into two families. Server-side layersFeatureLayer, OGCFeatureLayer, SceneLayer, StreamLayer — fetch only what they need on load and go back to the service for more as required. Client-side layersCSVLayer, GeoJSONLayer, WFSLayer, and a FeatureLayer built from an array of graphics through its source property — pull everything at once and never talk to a server again.

On a server-side layer, the guide is explicit: setting a definitionExpression “triggers a network request to fetch features that satisfy the definition expression.” That is the whole reason to use it. Features outside the expression are never downloaded, never rendered, never counted, and never left sitting in the browser. On a client-side layer the same property evaluates locally, against everything the layer already holds — same syntax, completely different payload story.

One more thing makes definitionExpression the natural floor of the stack: it is honored by everything above it. If a layer carries a definition expression, the guide notes that all layer view queries and filters honor it too. You can set the permanent restriction once, on the layer, and reason about every filter and query above it as operating inside that boundary.

It is a payload lever, not a security boundary. This is worth being blunt about, because the mistake is common and expensive. definitionExpression is a writable client property — the reference notes it can be set before the layer loads or after it has been added to the map. It becomes a where clause on the request the browser sends. The service endpoint is unchanged, it is sitting in the network tab, and one line in the console removes the clause. It keeps data off the wire for a cooperative client, which is a real and useful thing; it does not keep data away from anyone who does not wish to cooperate. If the requirement is “the public site must not expose the parcels we have flagged,” the answer has to be enforced on the server. That means a hosted feature layer view whose view definition is stored with the item, an ArcGIS Enterprise database view or filtered service, or a proxy that injects the clause and never exposes the raw service URL. Esri’s guidance for hosted views is that the definitions “are saved with the hosted feature layer view” — which is exactly the property a client-set expression lacks.

One practical note, because a clause in an article is cheap and the mechanism is not. Creating a hosted feature layer view requires you to own the source hosted feature layer or to be an organization administrator, and the source has to be a hosted layer rather than a referenced ArcGIS Server service. A developer consuming a partner’s service cannot do it, and the right next step there is a conversation with the data owner rather than a client-side workaround. On cost: hosted views reference the existing data rather than copying it, so they add no credits, and feature layer queries are not a credit-metered capability. The round trips in this article cost latency, not credits.

filter changes what draws, from what already arrived

A FeatureFilter assigned to FeatureLayerView.filter is the fast lever. There is no network request; the layer view re-evaluates the features it is already holding and redraws. For a criteria panel the user is dragging a slider on, that responsiveness is the entire point.

The class carries seven properties and no more: where, geometry, spatialRelationship, distance, units, timeExtent and objectIds. That is a complete list, and the absences tell you what a filter is for. There is no outFields, no orderByFields, no statistics. A filter affects visibility. It returns nothing.

The reference is blunt about the cost of the speed. A FeatureFilter “runs against features that are available for drawing on the client-side.” Those features are optimized for performance, so filter results are, in the docs’ own words, “not always accurate.” The same paragraph names the remedy: use the layer’s own queryFeatures() method, or a definitionExpression, when the filter has to run against every feature.

Two consequences follow, and both are invisible in a demo built on a two-hundred-feature layer. Attribute values are compared in the browser, and the guide warns that client-side attribute values are case sensitive — a where clause a service happily matched can quietly match nothing once it is being evaluated on the client. And geometries held by a layer view are generalized for drawing, so a spatial filter or a client-side area calculation returns results that the guide describes as imprecise, and that change as the user zooms.

The field you did not ask for is not there. A layer view only fetches the fields it needs for the renderer, the labeling and the elevation info, plus anything you named in the layer’s outFields. The fetched set is readable at FeatureLayerView.availableFields, and the query reference states the consequence plainly: ensure the fields being queried are in that list, “otherwise, the query may fail or return incomplete results.” Name the fields at layer construction: new FeatureLayer({ portalItem: { id }, outFields: ["zoning", "yearBuilt"] }). You can also set outFields after the layer has loaded, and Esri’s own samples do. But availableFields is populated only once the layer view finishes updating, so the constructor version is the one that is already correct when your first query runs.

featureEffect, when hiding is the wrong answer

Hiding non-matching features is a reflex, and on a lot of maps it is the wrong call. Show a planner the eleven parcels that match their criteria with everything else removed and you have taken away the thing that made the eleven meaningful: where they sit relative to everything they do not match. FeatureEffect exists for that case. It takes the same FeatureFilter and, instead of removing the non-matches, applies an effect to each side of the split — includedEffect for features that pass, excludedEffect for features that fail.

// Matches at full strength; everything else still on the map, grayed back.
layerView.featureEffect = new FeatureEffect({
  filter: new FeatureFilter({ where: "zoning = 'C-2'" }),
  excludedEffect: "grayscale(100%) opacity(30%)"
});

// Or emphasize the matches instead of suppressing the rest.
layerView.featureEffect = new FeatureEffect({
  filter: new FeatureFilter({ where: "BoroughEdge='true'" }),
  includedEffect: "drop-shadow(3px, 3px, 3px, black)",
  excludedEffect: "blur(1px) brightness(65%)"
});

Both effect properties are typed Effect, which accepts a CSS filter string as shown or an array of scale stops, so the treatment can change as the user zooms rather than being fixed for the whole map.

Set the effect on the layer and the layer view inherits it. Set it on both and the layer view wins. That inheritance is more useful than it looks: put the effect on the layer when it is a property of the map you are authoring, and on the layer view when it belongs to what this particular user is doing right now.

Persisting an effect into a web map has three conditions, not one. The reference states all three and they compound. A FeatureEffect set on a layer view cannot be persisted at all. Even on the layer, excludedLabelsVisible “must be set to true in order to persist the FeatureEffect to a WebMap,” and its default is false. And the effect “can only be persisted to a WebMap if an attribute filter is the only property set on FeatureFilter” — so the geometry-driven effect in Esri’s own samples cannot be saved into a web map under any arrangement. Author it in code, or accept that it lives for the session.

Keep featureEffect straight from its neighbor effect, which applies the same kind of treatment to the whole layer with no filter and no included-versus-excluded split. When several are set at once the reference gives the order they compose in — featureEffect, then effect, then opacity, then blendMode. If a layer-wide effect is already washing everything out, an excludedEffect stacked underneath it will not read as strongly as it does in the reference examples.

The three flags that decide whether to trust the client

Here is where the choice stops being cosmetic. Once a filter is on screen, somebody wants a number next to it — matches found, parcels affected, sites in range. The obvious move is to ask the layer view, because it is right there and it answers instantly. The obvious move is often wrong, and version 4.29 added three read-only booleans whose entire job is to tell you when.

FlagTrue meansFalse means
hasAllFeatures The layer view holds every feature from the source, so layer view queries run against the entire dataset It holds only what was needed for drawing; queries run against that subset
hasAllFeaturesInView Everything relevant to the current extent arrived, so a query scoped to the extent is answerable on the client Some queries failed, or the layer holds a large number of features and is configured with a display filter
hasFullGeometries Geometries are present at full resolution, without quantization Geometries are generalized for drawing — do not measure them

The guidance attached to each is the same shape: if the flag is true, the layer view can answer; if it is false, ask the layer, which goes to the service. Note the third one in particular. hasFullGeometries is the flag that separates “how many parcels intersect this buffer” from “how many acres do they cover.” The first can tolerate a generalized outline. The second cannot, and there is nothing in the returned geometry to warn you that it has been simplified.

All three settle only after the layer view has finished fetching, which is why every construction below waits first. And be clear about what a branch on them buys you. When hasAllFeatures is true the reference says a layer view query runs against the entire dataset, so the answer is right either way — the branch is saving a round trip, not rescuing correctness. Whether a given layer loads entirely on the client is an internal decision the SDK makes rather than a contract, so a branch taking the fast path today can take the slow path after an upgrade with no change to the answer, only to the latency.

A real job — a parcel panel that reports a defensible count

Take a county parcel layer of a couple of hundred thousand features and a side panel where an analyst sets criteria: commercial zoning, built before 1980. The panel shows the matches on the map and a count at the top.

The naive build sets a layer view filter and reads layerView.queryFeatureCount(). It is wrong in a way that is almost designed to escape review. It returns a number. The number looks plausible. And it counts only the features the layer view happens to be holding, which is a function of where the user has already panned and zoomed. The same criteria therefore produce different numbers in two sessions, and neither is the answer to the question the analyst asked.

The correct build separates the two questions. What the analyst sees is a drawing concern and belongs on the client, where it is instant. What the analyst quotes is a data question and belongs wherever it can actually be answered.

import FeatureLayer from "@arcgis/core/layers/FeatureLayer.js";
import FeatureFilter from "@arcgis/core/layers/support/FeatureFilter.js";
import FeatureEffect from "@arcgis/core/layers/support/FeatureEffect.js";
import * as reactiveUtils from "@arcgis/core/core/reactiveUtils.js";

// The fields the client-side effect reads must be fetched. Name them at
// construction so they are in availableFields before the first query runs.
const layer = new FeatureLayer({
  portalItem: { id: PARCELS_ITEM_ID },
  outFields: ["zoning", "yearBuilt"]
});

let seq = 0;   // monotonic request token - see the note on stale responses below

async function applyCriteria(view, layer, panel, where) {
  const ticket = ++seq;

  const layerView = await view.whenLayerView(layer);
  await reactiveUtils.whenOnce(() => !layerView.updating);

  // A later call may have overtaken us during the awaits above. Anything that
  // touches the map or the panel from here down is gated on the ticket.
  if (ticket !== seq) return;

  // What the analyst sees: matches at full strength, the rest grayed but present.
  layerView.featureEffect = new FeatureEffect({
    filter: new FeatureFilter({ where }),
    excludedEffect: "grayscale(100%) opacity(30%)"
  });

  // What the analyst quotes: ask the side that can actually answer.
  const count = layerView.hasAllFeatures
    ? await layerView.queryFeatureCount({ where })
    : await layer.queryFeatureCount({ where });

  if (ticket === seq) panel.showCount(count);
}

Four decisions in that construction earn their keep. outFields is set on the layer at construction, so the fields the filter reads are in availableFields by the time the layer view settles. The whenOnce guard is there because both hasAllFeatures and the query results are meaningless until the layer view has stopped fetching. The effect deliberately removes nothing, so the analyst can still see the blocks the matches sit in. And the ticket comparison is the difference between a panel that shows the latest answer and one that shows whichever answer arrived last. On a criteria panel the user is actively adjusting, those are not the same thing, and the second is the exact defect this article exists to prevent.

One note on that ternary. It is an optimization, not a correctness mechanism — both branches are documented to return the same answer, and the flag only decides whether you pay for a round trip. If you would rather not depend on an SDK-internal decision at all, delete it and always ask the layer. You lose a few milliseconds and gain one less thing to re-verify after an upgrade.

What that panel still owes you

The snippet above is the shape, not the shipping version. Three things separate them.

Cost. Every criteria change that takes the service branch is a round trip. Debounce the handler and memoize on the inputs that determine the answer — the where clause, plus the extent if the query is extent-scoped. A count is one of the most cacheable things an application asks for and one of the most frequently re-requested. On a rate-limited service, an unthrottled criteria panel is a throttling event rather than merely a slow one. Cancellation is available too: FeatureLayerView.queryFeatureCount() takes an AbortOptions second argument, so a superseded request can be dropped outright rather than ignored on arrival.

The list after the count. Every results panel that starts as a number eventually becomes a list, then an export. That is where maxRecordCount arrives — the guide is explicit that paginated queries are required for more than the service’s maximum record count. A count is one request; a list is a paging design. Do not let the count’s simplicity set expectations for what follows it.

Staleness. This architecture reads the map from a client cache and the number from the service, which is two sources that can disagree. On an editable operational layer the panel can legitimately print a count for features the map has not drawn yet. And if the count should follow the map as the user pans, watch the fetch cycle rather than the extent. The shape below is the one the hasAllFeaturesInView reference uses in its own example:

reactiveUtils.when(() => !layerView.dataUpdating, async () => {
  const target = layerView.hasAllFeaturesInView ? layerView : layer;
  panel.showCount(await target.queryFeatureCount({ where, geometry: view.extent }));
});

The switch from updating in the first construction to dataUpdating here is deliberate. updating is true whenever the layer view is busy, including re-rendering after an effect changes. dataUpdating is the narrower signal that features are being fetched, which is what a count depends on.

Two caveats on that snippet, both worth knowing before you paste it. when fires on a transition into truthy, so if the layer view has already settled at registration time the callback waits for the next fetch cycle; pass { initial: true } if you need the first render populated. And the dataUpdating reference itself recommends the stricter pattern of watching updating and dataUpdating together with a latch, because dataUpdating can only be true while updating is. Use the simple form for a panel; use the latch when you must be certain a full update cycle completed.

Display filters, the fourth lever

The flag table above names a display filter without defining it, and it deserves the paragraph, because at 5.1 it is often the right answer to the dense-map problem behind the parcel example.

DisplayFilterInfo, added at 4.32, controls which features are drawn. The reference’s own summary is that it lets you “display a subset of features while retaining access to all features for querying and analysis.” Unlike definitionExpression, which “filters data at the source level,” display filters “only affect visibility on the map.” Its canonical use is scale-dependent decluttering: major rivers when zoomed out, tributaries as you zoom in. On a two-hundred-thousand-parcel layer that is the difference between a map that is usable at county extent and one that is not — and the featureEffect approach, which keeps every non-match on the map, is the option that needs it most.

It also closes the loop on the flag. The same reference explains that display filters “may be appended to the layer’s definitionExpression when querying the service.” The consequence, in its words: “the filtered features may not be available on the client for executing layer view queries.” It then names the check, the one recommended above: read hasAllFeaturesInView once dataUpdating is false, and query the layer if the flag is false. The reference’s own warning is this article’s thesis restated: display filters “should be ignored when querying data to present to users.”

Five places a filter or an effect quietly does nothing

Each of these is documented, and each produces the same symptom: correct-looking code and no visible change.

  1. A feature effect in a 3D scene. The reference lists SceneView among the scenarios FeatureEffect does not support. Layer-wide effect is unsupported in 3D as well. A 2D prototype that ports to a scene loses both silently.
  2. A feature effect on a clustered layer. With FeatureReductionCluster enabled, feature effect is not supported — and neither is layer effect. The features you are trying to emphasize are not drawn as features any more — so if clustering is the answer to a slow map, a display filter or a scale threshold becomes the tool for context instead.
  3. A printed map. The layer view reference lists “when a map is printed” among the scenarios feature effect does not support. The layer-wide effect has its own printing constraints, which are conditional rather than blanket. If a report export is part of the deliverable, verify the printed output early rather than at handover.
  4. An effect that had to survive a save. Covered in the callout above: layer view effects never persist to a web map, and layer effects persist only with excludedLabelsVisible set true and an attribute-only filter. This one works perfectly until somebody saves the map.
  5. A where clause on a field nobody fetched. As a filter this fails silently, because there is nothing wrong with the clause — there is just nothing to evaluate it against. As a query it is less forgiving: the reference warns that a query on a field outside availableFields may fail outright or return incomplete results.

Grayed out is not filtered out — the accessibility half

The argument for featureEffect over removal is that context is information. That argument has an obligation attached, and it is the one most often skipped.

grayscale(100%) opacity(30%) is the excluded effect in most Esri samples and in this article. At 30 percent opacity over a light basemap it will often fall below the 3:1 non-text contrast floor WCAG sets for meaningful graphics. It also removes color as a cue, which is the right instinct for a color-blind reader only if something else carries the distinction. Measure the excluded state against your actual basemap rather than trusting the sample, and prefer an included effect that adds emphasis over an excluded effect that subtracts everything.

The deeper point is that map features are drawn to a canvas, so neither the grayed features nor the hidden ones exist in the accessibility tree. For a screen reader user the distinction this section is built on does not exist, and the results panel is the map. That makes the count more important, not less — announce it in a live region so a change of criteria is perceivable without sight of the map.

Choosing, in one line each

  1. Must never reach the user — a server-enforced mechanism: a hosted feature layer view with a view definition, an Enterprise database view, or a proxy. definitionExpression reduces payload; it does not enforce anything.
  2. Must respond instantly to a control the user is holdingFeatureFilter on the layer view, over fields you named in outFields.
  3. Must keep the non-matches on screen for contextFeatureEffect, with excludedEffect rather than removal, and a contrast check on the result.
  4. Must declutter a dense map without hiding data from queries — a display filter, and then hasAllFeaturesInView before you trust a client-side count.
  5. Must produce a number somebody will act on — query the layer, debounce it, and reject superseded responses. hasAllFeatures can skip the round trip when it is true; it changes the latency, not the answer.
  6. Must produce a measurement — ask the service for the statistic with outStatistics rather than downloading geometry to sum it in the browser. hasFullGeometries is the flag that tells you client-side measurement was never on the table.
If you are not the one writing this code. The failure this article describes does not look like a bug. It looks like a working application with a number in the corner, and the number depends on where the user has already been. It survives testing because test datasets are small enough that the browser holds all of them, and it surfaces in production on the biggest layer you own — usually when somebody puts the figure in a memo. The review question is one line: where does the count in this panel come from, and does it change when I move the map without changing the criteria? The fix is normally small — move the count to a service query, debounce it, discard superseded responses — and it costs one network request per interaction, which is exactly why it was skipped. Worth an afternoon before a client asks.

References

Is the number in your map application defensible?

We review ArcGIS Maps SDK applications for exactly this class of defect — counts read from the wrong side of the pipeline, filters evaluated against fields nobody fetched, and restrictions implemented as visibility rather than as policy. Book a free intro call and we will talk through what a review would find on your stack.

Book a free intro call