Sync tags
How Content Lake generates sync tags for each query, the limit on tags per response, and how to write queries that keep live updates precise.
Sync tags are opaque identifiers that Content Lake attaches to every query response to describe which content the response depends on. When content changes, the Live Content API emits the sync tags for that change, and any client or cache holding a response with a matching tag refetches it.
How specific a response's sync tags are decides how often it refetches. A response tagged with one document ID refetches when that document changes. A response tagged with a whole document type, or with the whole dataset, refetches on every change in that scope. This article explains how Content Lake derives sync tags from a query, the limit on how many a response can carry, and how to write queries that produce precise tags.
Get the sync tags for a query
Query responses include a syncTags array on API version v2021-03-25 or later. With @sanity/client, set filterResponse: false to receive the tags alongside the result:
import {createClient} from '@sanity/client'
const client = createClient({
projectId: '<your-project-id>',
dataset: 'production',
apiVersion: '2026-03-01',
useCdn: true,
})
const {result, syncTags} = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{slug: 'hello-world'},
{filterResponse: false},
)Treat each tag as an opaque string. Tags are hashed per project and dataset, so you can compare them for equality, but you can't read a document ID or type back out of one. next-sanity prefixes each tag with sanity: and passes it to Next.js as a cache tag: through cacheTag when Cache Components is enabled, and through the next.tags fetch option otherwise. Next.js stores the tags with the cached response and decides when to invalidate it.
How Content Lake generates sync tags
Content Lake tries to give each response the narrowest set of tags that still catches every change that could affect it. Narrower tags mean fewer refetches and a higher cache hit rate. To find them, Content Lake looks at how the query selects documents and predicts which documents would change the result if they were created, edited, or deleted. Three kinds of filter let it name those documents by value:
_id == $idor_id in $ids: one tag per document ID.slug.current == $slugorslug.current in $slugs: one tag per slug value. Slugs are usually unique, so Content Lake treats them like document IDs. This applies to any field path that ends inslug.current, such asstore.slug.current._type == "post"or_type in ["post", "author"]: one tag per document type. A change to any document of that type matches it, so a type tag is much broader than an ID or slug tag.
Filters on anything else can't produce a value-specific tag. That includes your own fields, references(), match, and defined(). If the same filter also checks _type, the response gets the type tag. If it doesn't, the response gets a dataset-wide tag that matches every change in the dataset. A query with no filter at all, like *[0], also gets the dataset-wide tag.
When a filter combines conditions with &&, the response keeps the most specific tag: a document ID wins over a slug, and a slug wins over a type. When a filter combines conditions with ||, the response gets the tags from both sides.
Every part of a query that reads documents contributes tags. Each subquery adds the tags for its own filter, and each reference you follow with -> adds a tag for the referenced document's ID:
// One tag for the post's ID, plus one tag per referenced author
*[_type == "post" && _id == $id][0]{
title,
"authors": authors[]->{name}
}Tags come from the values in the query, not from the documents it finds. *[slug.current == "hello-world"] gets the tag for the slug hello-world even when no document has that slug yet, and the response refetches only when a document with that slug is created, edited, or deleted. References work the same way: following a reference with -> looks up its _ref as a document ID, so the response gets a tag for that ID even when the document doesn't exist yet, such as a weak reference to a document that hasn't been published.
Perspectives add tags. Under the drafts perspective, each document ID produces two tags: one for the published ID and one for the draft ID. Under a release perspective, each ID also produces one tag per release in the perspective, plus one for the draft ID when the perspective includes drafts.
The sync tag limit
A query response carries fewer than 100 sync tags. Every value-specific tag counts toward the limit, including the type tag from a _type filter and the extra tags that perspectives add. When a query would need 100 or more tags, Content Lake replaces the precise tags with broader ones:
- If the filter checks
_type, the response collapses to the type tag. - If it doesn't, the response collapses to type tags for the documents it read where Content Lake can determine them, and otherwise to the dataset-wide tag.
For example, *[_id in $ids] with 99 IDs gets 99 tags, and with 100 IDs it collapses. Adding _type == "post" && to the filter adds one tag, so the query collapses to the post type tag at 99 IDs. Under the drafts perspective, each ID counts twice, so *[_id in $ids] collapses at 50 IDs.
A collapsed response still updates correctly, but it refetches on every change to any document of that type, or anywhere in the dataset.
Write queries that produce precise sync tags
Use these patterns to keep each response's sync tags narrow and under the limit:
- Filter by
_typein every query. It costs one tag, and it guarantees that a long_id inlist that goes over the limit falls back to a type tag rather than the dataset-wide tag. - Select documents by
_idorslug.currentwhere you can. For a list of known documents,_type == "post" && _id in $idsis the most precise shape. - Keep lists of IDs under the limit. Count the type tag, and count two tags per ID under the
draftsperspective. - Fetch broad content in its own query. A "latest posts" list like
*[_type == "post"] | order(_createdAt desc)[0...10]gets theposttype tag, so it refetches on every post change. Fetched in the same query as a single post, it makes the whole response refetch on every post change. Splitting queries sends more requests, so weigh the trade-off described in High-performance GROQ.
How sync tags affect API usage
Live Content API connections don't request data. Holding a connection open, for example by rendering <SanityLive> in a Next.js root layout, counts toward Live Content API usage but doesn't send queries on its own. The refetches that matching sync tags trigger are ordinary queries, and they count toward your API request usage.
Two things decide how many refetches a change causes:
- How many cached responses carry a matching tag. A type tag matches every cached response for that type, and the dataset-wide tag matches every response that carries it, whatever changed.
- Where the refetch happens. With next-sanity, revalidation happens in the Next.js cache on your server, so the number of refetches follows the number of affected cache entries. See Caching and revalidation in Next.js.
Sync tag events come from any write to a document, not only from publishing in Sanity Studio. Imports, migrations, and scripts that write to published documents emit events like any other edit, and every cached response with a matching tag refetches.
On Next.js 16, next-sanity v12 can multiply request volume when <SanityLive> is rendered. next-sanity v13 changes the default invalidation behavior to avoid this. See Next.js 16 and SanityLive: avoiding request overages.
Reduce refetch spikes from large imports and migrations
A bulk write to a production dataset emits sync tag events for every document it changes, and responses with type or dataset-wide tags refetch for each of them. To soften the spike:
- Use next-sanity v13 or later. Its
<SanityLive>revalidates with a stale-while-revalidate profile by default, so invalidated entries refetch when they're next requested instead of all at once. See Add live content to your application. - Run large scripts off-peak, when fewer visitors request the invalidated entries.
- Centralize invalidation with a Sync Tag Invalidate Function and set
waitFor="function"on<SanityLive>(next-sanity ignoreswaitForwhenincludeDraftsis enabled). The function must calldone()for every event. Until it does, clients that wait on the function don't receive the change.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?