The Metaobject Query That Scanned Every Entry on Every Page
A theme started getting slower as the merchant added content. Product pages, collection pages, even the blog, all of them. The merchant had done nothing wrong except use metaobjects the way they are meant to be used, for a growing catalogue of store locators, size guides and brand stories.
The culprit was a query that fetched metaobject entries and then filtered them in Liquid. Because the filter it needed was not one the metaobject API can push down, every page view was loading the entire collection of entries and throwing most of them away.
This is a gentle feature becoming a performance tax, and the fix is about knowing which filters run server side and which run in your template.
This was on an Online Store 2.0 theme, observed in Chrome 133, with a few thousand metaobject entries.
What metaobjects are and why the query shape matters
Metaobjects let a merchant define custom structured content. A definition has fields, and entries are instances of it. Themes read them through Liquid or the Storefront API.
The important property is that the server can filter and paginate on some fields, notably the handle, the id and references, but not on arbitrary custom fields. If you want entries where a custom field equals some value, that predicate is not something the backend can evaluate for you in the general case. So the honest options are to fetch by something indexable or to fetch a page and inspect.
The anti pattern is to fetch everything and inspect in Liquid:
{% for entry in shop.metaobjects.some_definition %}
{% if entry.fields.region == current_region %}
... render ...
{% endif %}
{% endfor %}
That loop reads every entry. With a few thousand entries and a few dozen such loops across page types, you are paying for the whole catalogue on every request, and the cost grows with the merchant's success.
How to see it
The tell is in the rendered page's data and in timing.
Time the section or the page and watch it scale with the entry count, not with what is displayed. A page that shows one size guide should not get slower as you add nine hundred more size guides. If it does, you are scanning.
In the Storefront or theme context, you can also observe the payload. If a page's GraphQL or Liquid context contains the full list of entries when the UI needs one, the scan is real.
The same "fetch all then filter in the template" shape exists for products and collections, but it bites hardest with metaobjects because their count is unbounded and because they are often loaded on every page type via the header or a global section.
The fixes
Filter by what the backend can index
If the thing you are filtering on can be expressed as a handle or a reference, do it server side:
{% assign guide = shop.metaobjects.size_guide | where: "handle", current_handle | first %}
Even better, fetch a single entry by handle where the API supports it, so the backend does the work and you transfer one record instead of thousands.
Model the relationship, not the lookup
If a product page needs the size guide for that product, do not scan all guides and match. Put a reference from the product to the guide, or encode the mapping in a field the backend can use. A reference field turns the scan into a direct read. This is the difference between a query and a pointer, and it is the fix that scales.
Cache the scan if you must keep it
If the content truly requires a custom field predicate and you cannot re model it, at least stop paying for it per request. Render the derived result once into a metaobject or a metafield, or cache it at the edge with a sensible TTL, and invalidate when entries change. The scan becomes a background job instead of a per page cost.
Bound it with pagination
Where you cannot avoid reading a list, paginate and read only the first page. A scan of the first fifty entries is a tax. A scan of all entries is a liability. Bound the blast radius now and fix the model later.
Why this is a recurring theme
It is the same defect as the metaobject and N plus one patterns and as the export silently capped at 1000 rows in reverse. Somewhere a boundary exists between what the server can do efficiently and what the template must do clumsily, and the code crossed it without noticing.
The general rule for any data layer: find out which predicates the backend can evaluate, push those down, and treat everything else as a scan you must bound, cache or re model. Liquid will happily loop over ten thousand things. That is a capability, not a recommendation.
The review checklist
For any theme that uses metaobjects, ask three questions in review. Does the page transfer only the records it displays. Is every filter expressed on an indexable field, or is there a Liquid if doing the filtering. And does the cost of the page grow with content the page never shows.
If the answer to the last one is yes, you have a scan, and the merchant will discover it for you the day their catalogue outgrows your loop.
If you are building the query today rather than repairing one, the habit to form is writing the Liquid after confirming the predicate runs server side. It is far cheaper to design the model around an indexable field than to unwind a scan that has shipped.
Metaobjects reward good modelling and punish lazy fetching, and the difference between the two is one query. The broader lesson of fetching the wrong amount of data at the edge is also the subject of serving a 2400px image to a 390px phone.