Designing for query performance
Designing for query performance
TL;DR
- Each level of reference include[] depth adds latency and payload size -- keep resolution to two levels maximum.
- Use only[BASE][] for list views to return only the fields your UI actually needs, avoiding multi-second payloads at scale.
- Blend normalization and denormalization: normalize entities with independent lifecycle, denormalize display-only data that rarely changes.
- Test with realistic data volumes early -- payloads that seem fine in development become performance problems at production scale.
Every content model is also a query contract. The fields you define, the references you create, and the modular blocks you allow all directly determine the size, speed, and cost of every API response your frontend will consume. A content model that looks clean in the Contentstack UI can produce API responses that are slow, oversized, or require cascading requests to assemble a single page.
This lesson focuses on how modeling decisions affect Delivery API performance, and how to design models that produce efficient queries from the start rather than requiring optimization after launch.
Reference depth and the include[] parameter
As covered in lesson 2.2.1, reference fields require the include[] parameter to resolve referenced entry data inline. Each level of reference nesting you resolve adds latency and payload size to the response.
How include depth works
Consider this reference chain:
- product references product_line (e.g., Digital Dawn)
- product references category (e.g., Earrings, Bracelets)
- product references related_products (which are also product entries)
- each related_product references its own product_line and category
A naive query that resolves everything looks like this:
GET /v3/content_types/product/entries/{uid}
?include[]=product_line
&include[]=category
&include[]=related_products
&environment=productionThis resolves the first level of references. Each related_product has its own product_line and category references. To resolve those, you need deeper inclusion.
Contentstack's Content Delivery API supports a reference depth parameter to control how many levels of nested references are resolved. The include_reference_content_type_uid parameter also includes the _content_type_uid for resolved references. However, the platform enforces limits on how deep references can be resolved in a single request.
The depth limit
Contentstack limits reference include depth. Beyond the supported depth, nested references return as unresolved UIDs. This is not a bug; it is a guardrail. Deep reference chains in a single API call produce responses that are slow to compute, large to transfer, and expensive to parse.
Common pitfall: Ignoring payload size until production leads to multi-second page loads -- development datasets are small, but a 2,000-entry catalog with rich text and images will expose every over-fetching pattern.
If your content model requires more than two or three levels of reference resolution, that is a signal to reconsider the model, not to work around the depth limit.
Each level adds latency
Reference resolution is not free. Each additional include level requires the API to fetch and embed more entries. A query that resolves one level might return in 80ms. Adding a second level might push it to 200ms. Adding a third might exceed 400ms, depending on the number of entries at each level.
For a Veda product detail page that resolves product_line, category, and five related products (each with their own product_line and category), a two-level deep include could mean resolving 1 + 1 + 5 + 10 = 17 entries in a single API call. That cost is paid on every request.
Payload size budgets
API performance is not only about latency. Response payload size directly affects transfer time, client memory usage, and parsing cost, especially on mobile devices and constrained networks.
What drives payload bloat
Several modeling patterns produce oversized responses:
Rich text fields in referenced entries. If a product entry has a JSON RTE description field that contains 5KB of structured content, and you return 20 products in a list query with include[]=product_line, each response carries 100KB of rich text data that your list page does not render.
Modular blocks with many block instances. As noted in lesson 2.2.1, modular block data is always inline. A landing page with 15 modular block sections, each containing images, rich text, and nested groups, can produce a single entry response exceeding 50KB. Unlike references, you cannot selectively exclude modular block data.
Multi-reference fields with large arrays. A related_products field that allows 20 references, each resolved with include[], can multiply payload size dramatically.
Unused fields in list queries. Querying a list of 25 entries where each entry has 30 fields, but your UI only displays title and thumbnail, transfers 28 unused fields per entry.
Setting a payload budget
A practical guideline: aim for individual entry responses under 50KB and list query responses under 200KB after reference resolution. These are not hard limits from Contentstack, but they represent thresholds where frontend performance starts degrading on real devices.
Monitor actual payload sizes in your integration. Use the only[BASE][] parameter to select specific fields when you do not need the full entry:
GET /v3/content_types/product/entries ?environment=production &only[BASE][]=title &only[BASE][]=slug &only[BASE][]=thumbnail &only[BASE][]=price &limit=25
This returns only the four specified fields per entry, dramatically reducing payload for list views. Combine this with except[BASE][] to exclude specific heavy fields while keeping the rest.
The include_count parameter
When building paginated UIs, you often need the total number of matching entries without fetching all of them. The include_count parameter adds a count value to the response:
GET /v3/content_types/product/entries
?environment=production
&query={"category":"blt_earrings_001"}
&limit=10
&skip=0
&include_count=trueResponse:
{
"entries": [...],
"count": 247
}This count comes with a small performance cost, but it is far cheaper than fetching all entries to count them client-side. Use it when pagination controls require total counts. Skip it when infinite scroll or "load more" patterns do not need a total.
Normalized vs denormalized models
This is the fundamental trade-off in content modeling for API-driven delivery: how much do you normalize (DRY, fewer duplicates, more references) versus denormalize (redundant data, fewer API calls, larger entries)?
Normalized model characteristics
A fully normalized e-commerce catalog might look like:
- product references product_line (separate content type)
- product references category (separate content type)
- product references related_products (same content type)
- Veda: Product, Product Line, Category - each with clear references
- product references related_products (self-referencing)
Benefits:
- Single source of truth for each entity
- Update a brand name once, it propagates everywhere
- Clean domain model that maps to business concepts
Costs:
- Rendering a product detail page requires resolving 4+ reference fields
- List pages either under-resolve (missing data) or over-resolve (slow, heavy)
- Deep reference chains hit depth limits
- Multiple API calls may be needed to assemble one view
Denormalized model characteristics
A denormalized version of the same catalog:
- product contains brand_name and brand_logo as inline fields (not a reference)
- product contains category_name and category_slug as inline fields
- product contains a variants group field with options embedded directly
- product references related_products but only at one level
Benefits:
- Product detail page requires a single API call with minimal includes
- Predictable payload size
- No depth limit issues
- Faster response times
Costs:
- Changing a brand name requires updating every product that uses it
- Category restructuring means touching many entries
- Data inconsistency risk if updates are missed
- Larger individual entries due to duplicated data
The practical middle ground
Pure normalization and pure denormalization are both extremes. Effective models blend both:
- Normalize entities with independent lifecycle and frequent reuse: brands, authors, categories that appear in navigation and filtering.
- Denormalize display-only data that rarely changes: a brand_name string on a product for list views, avoiding a reference resolution just to show a name.
- Use modular blocks for page-specific composition: sections that belong to one page and have no reuse requirement (see lesson 2.2.1).
- Limit reference depth to two levels maximum: if your model requires three or more levels, flatten the intermediate layer.
Worked example: e-commerce product catalog
Consider a product catalog with the following naive normalized model:
product
├── brand (reference → brand)
├── category (reference → category)
│ └── parent_category (reference → category)
│ └── grandparent_category (reference → category)
├── related_products (reference → product, multiple)
│ ├── brand (reference → brand)
│ └── category (reference → category)
└── variant_options (reference → variant_option, multiple)
└── variant_group (reference → variant_group)This model has five levels of potential reference depth. Resolving it fully in one request is impossible within depth limits and would produce enormous payloads regardless.
Flattened approach
Restructure the model to stay within two levels of reference depth:
product ├── brand (reference → brand) [Level 1] ├── category_path (JSON field: ["Electronics", "Audio", "Headphones"]) ├── primary_category (reference → category) [Level 1] ├── related_products (reference → product, max 4) [Level 1] ├── variants (group field, multiple) [Inline] │ ├── variant_label (single line) │ ├── variant_sku (single line) │ ├── variant_price (number) │ └── variant_image (file) └── variant_group_name (single line) [Denormalized]
Key changes:
- Category hierarchy flattened: instead of chained category references, store the full category path as a JSON field for display and use a single primary_category reference for querying and filtering.
- Variant options embedded as groups: variant data is modeled as a group field within the product instead of a separate referenced content type. This eliminates one reference level entirely.
- Related products capped: limiting to 4 related products controls payload growth.
- Variant group denormalized: the group name is stored as a string on the product, avoiding another reference level.
Query comparison
Naive model query (attempting full resolution):
GET /v3/content_types/product/entries/{uid}
?include[]=brand
&include[]=category
&include[]=related_products
&include[]=variant_options
&environment=productionPotential entries resolved: 1 product + 1 brand + 1 category + 1 parent category + 4 related products + 4 related brands + 4 related categories + 6 variant options + 6 variant groups = 28 entries. Many of these will be unresolved due to depth limits, resulting in broken UI.
Flattened model query:
GET /v3/content_types/product/entries/{uid}
?include[]=brand
&include[]=primary_category
&include[]=related_products
&environment=productionEntries resolved: 1 product + 1 brand + 1 category + 4 related products = 7 entries. All within a single include level. Variants and category path are inline. Response is predictable and bounded.
List view optimization
For product listing pages that show title, image, price, and brand name, use field projection:
GET /v3/content_types/product/entries
?environment=production
&query={"primary_category":"bltcategory123"}
&only[BASE][]=title
&only[BASE][]=slug
&only[BASE][]=thumbnail
&only[BASE][]=price
&only[BASE][]=brand
&include[]=brand
&limit=20
&include_count=trueThis returns only the fields needed for the list card, with brand resolved at one level. The category_path JSON field means you can display breadcrumbs without resolving category references at all.
Content type field count limits
Contentstack enforces limits on the number of fields per content type. While the exact limits depend on your plan, exceeding practical field counts creates both performance and editorial experience problems. Content types with 40+ fields produce large schema payloads, slow editor load times, and unwieldy editorial interfaces.
If a content type is growing beyond 25-30 fields, consider:
- Extracting reusable field groups into global fields (see lesson 2.1.3)
- Moving rarely-edited metadata into a separate linked content type
- Using modular blocks to make sections optional rather than defining all possible fields at the top level
Common mistakes
1. Resolving all references on every query
Fetching a list of articles with include[]=author&include[]=category&include[]=related_articles&include[]=tags when the list view only shows title and date. Use only[BASE][] for list views and reserve full resolution for detail pages.
2. Ignoring payload size until production
Development datasets are small. When the product catalog grows to 2,000 entries with full rich text descriptions and multiple image assets, the payloads that seemed fine in development become multi-second downloads. Test with realistic data volumes early.
3. Modeling deep hierarchies as chained references
Category trees modeled as category → parent → grandparent → root create unbounded reference depth. Store the hierarchy path as a denormalized field and use a single reference to the leaf category for query purposes.