Advanced client patterns with @sanity/client

Learn about mutation options, request cancellation, and other advanced patterns when using the Sanity JavaScript client.

This guide covers advanced patterns for @sanity/client: mutation options, automatic retries, canceling requests, handling errors, and routing requests through a proxy.

The mutation options on this page apply to every mutation method: create(), patch().commit(), delete(), and transaction().commit(). See Creating and updating documents and Creating transactions for the basics. The examples that write documents assume a client created with useCdn: false and a token that has write access.

Mutation options

Control how mutations execute with options for visibility, dry runs, and array key generation. These options apply to any mutation method, not only transactions.

Visibility: sync, async, and deferred

The visibility option controls when mutations become visible to queries:

  • sync: The mutation completes and indexes before returning. Queries immediately see the changes. This is the default behavior.
  • async: The mutation returns immediately but indexes in the background. Queries may not see changes right away.
  • deferred: The mutation commits before the request returns, but isn't indexed in real time. A background process makes it visible to queries later, within seconds to minutes. Use this for bulk writes you don't need to query right away.
// Sync: wait for indexing (default)
await client.create(
  {_type: 'post', title: 'Hello'},
  {visibility: 'sync'}
)

// Async: return immediately, index in background
await client.create(
  {_type: 'post', title: 'Hello'},
  {visibility: 'async'}
)

// Deferred: commit now, skip real-time indexing
await client.create(
  {_type: 'analytics', event: 'page_view'},
  {visibility: 'deferred'}
)

// Use deferred for bulk operations
const transaction = client.transaction()
for (const item of largeDataset) {
  transaction.create({_type: 'product', ...item})
}
await transaction.commit({visibility: 'deferred'})

Dry run mode

Test mutations without applying changes using dryRun: true. The API validates the mutation and returns what would happen, but does not modify any documents:

// Test a mutation without applying it
const result = await client.create(
  {_type: 'post', title: 'Test Post'},
  {dryRun: true}
)

console.log('Would create:', result)
// Document is not actually created

// Test a transaction
const txResult = await client
  .transaction()
  .create({_id: 'author-jane', _type: 'author', name: 'Jane'})
  .patch('post-123', (p) =>
    p.set({author: {_type: 'reference', _ref: 'author-jane'}})
  )
  .commit({dryRun: true})

console.log('Transaction would execute:', txResult.results)
// No changes are applied

Auto-generate array keys

By default, the API doesn't add _key values to array items. Set autoGenerateArrayKeys: true to have it add a unique _key to any array object that's missing one. Otherwise, provide your own _key values:

// Let the API add a _key to each array item
await client.create(
  {
    _type: 'post',
    title: 'Hello',
    tags: [
      {_type: 'tag', name: 'javascript'},
      {_type: 'tag', name: 'sanity'}
    ]
  },
  {autoGenerateArrayKeys: true}
)
// Each tag gets a unique _key

// Provide your own keys
await client.create({
  _type: 'post',
  title: 'Hello',
  tags: [
    {_key: 'js-tag', _type: 'tag', name: 'javascript'},
    {_key: 'sanity-tag', _type: 'tag', name: 'sanity'}
  ]
})

Automatic retries

@sanity/client retries some failed requests on its own. Retries are on by default, so you don't need to add your own backoff around a query. Automatic retries arrived in v6; on an older version, see Upgrade @sanity/client from an older major version.

The client retries a request when the response status is 429 Too Many Requests, 502 Bad Gateway, or 503 Service Unavailable, and the request is one of these:

  • A GET or HEAD request, such as getDocument().
  • A query to the /data/query endpoint, including a query long enough that the client sends it as a POST.

A GET or HEAD request that fails with a network error, such as ECONNRESET, ECONNREFUSED, or ETIMEDOUT, is retried with the same backoff. The client also retries DNS ENOTFOUND failures on idempotent requests, meaning GET and HEAD, using the same backoff. A hostname that never resolves, such as a typo in a custom API host, takes the full retry sequence to fail rather than failing at once. Set maxRetries: 0 to fail immediately instead. The ENOTFOUND retry applies to @sanity/client v8 and later.

Mutations are not retried

Retry defaults

Two ClientConfig options control retries:

  • maxRetries: How many times to retry a failed request. Defaults to 5. Set it to 0 to turn retries off.
  • retryDelay: A function that receives the attempt number, starting at 0, and returns how long to wait in milliseconds. Defaults to exponential backoff with jitter: 100 * 2 ** attemptNumber plus a random 0 to 100 milliseconds.

With the defaults, the waits are roughly 100, 200, 400, 800, and 1,600 milliseconds, each plus up to 100 milliseconds of jitter. A request that exhausts all five retries fails after about 3.1 to 3.7 seconds.

import {createClient} from '@sanity/client'

// Retry more patiently: eight retries, backing off from 250ms
export const client = createClient({
  projectId: '',
  dataset: 'production',
  apiVersion: '2026-03-01',
  useCdn: true,
  maxRetries: 8,
  retryDelay: (attemptNumber) => 250 * 2 ** attemptNumber + Math.random() * 100,
})

// Turn retries off entirely
export const noRetryClient = createClient({
  projectId: '',
  dataset: 'production',
  apiVersion: '2026-03-01',
  maxRetries: 0,
})

Cancel requests

Cancel in-flight requests using AbortController or by unsubscribing from Observables. This is useful for cleaning up requests when components unmount or when user actions make a request obsolete.

Cancel with an abort signal

Pass an AbortSignal to any client method to enable cancellation. Aborting cancels only the client's request, so a mutation that already reached Content Lake can still be applied:

const controller = new AbortController()
const {signal} = controller

// Start a mutation
const mutationPromise = client.create(
  {_type: 'post', title: 'Hello'},
  {signal}
)

// Cancel the request after 1 second
setTimeout(() => {
  controller.abort()
  console.log('Request canceled')
}, 1000)

try {
  await mutationPromise
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Request was aborted')
  }
}

// Use with transactions
const txController = new AbortController()

const transaction = client
  .transaction()
  .create({_type: 'post', title: 'Post 1'})
  .create({_type: 'post', title: 'Post 2'})

try {
  await transaction.commit({signal: txController.signal})
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Transaction was aborted')
  }
}

Unsubscribe from an observable

When using the Observable API, unsubscribe to cancel the request:

const subscription = client
  .observable
  .create({_type: 'post', title: 'Hello'})
  .subscribe({
    next: (result) => console.log('Created:', result),
    error: (error) => console.error('Error:', error),
    complete: () => console.log('Complete')
  })

// Cancel by unsubscribing
setTimeout(() => {
  subscription.unsubscribe()
  console.log('Unsubscribed from mutation')
}, 1000)

// Use with transactions
const txSubscription = client.observable
  .transaction()
  .create({_type: 'post', title: 'Post 1'})
  .create({_type: 'post', title: 'Post 2'})
  .commit()
  .subscribe({
    next: (result) => console.log('Transaction result:', result),
    error: (error) => console.error('Transaction error:', error)
  })

// Unsubscribe to cancel
txSubscription.unsubscribe()

Cancel requests when a React component unmounts

This example shows how to cancel requests when a React component unmounts:

import {useEffect, useState} from 'react'
import {client} from './sanityClient'

interface Post {
  _id: string
  title: string
}

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{_id, title}`

export function usePost(slug: string) {
  const [post, setPost] = useState<Post | null>(null)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const controller = new AbortController()

    client
      .fetch<Post | null>(POST_QUERY, {slug}, {signal: controller.signal})
      .then(setPost)
      .catch((err: Error) => {
        if (err.name !== 'AbortError') {
          setError(err)
        }
      })

    // Cleanup: abort the request if the component unmounts or the slug changes
    return () => controller.abort()
  }, [slug])

  return {post, error}
}

Handle errors

When the API responds with an error status, the client rejects with one of two error classes, both exported from @sanity/client:

  • ClientError: The status is 4xx. The API rejected the request, for example because the token is invalid (401), the token lacks permission (403), or the request hit a rate limit (429).
  • ServerError: The status is 5xx. The API failed. A read is safe to retry after a delay. A mutation may already have been applied, so retry it only when repeating it is harmless, such as createOrReplace() or patch().set() on a known document ID.

Both extend Error and carry the same structured fields:

  • statusCode: The HTTP status code.
  • responseBody: The response body, as a formatted JSON string when the API returned JSON.
  • details: The API's structured error object when it sent one, such as a mutationError with one items entry per failed mutation, or a queryParseError for a GROQ query that doesn't parse. Otherwise undefined.
  • traceId: An identifier for the request on Sanity's side. Include it when you contact support.
  • response: The raw response, with headers, url, method, and statusMessage.

Branch on these fields rather than on error.message. The message is written for humans and its format changes between versions: v8 added the HTTP status text, so a message that ended in HTTP 400 now ends in HTTP 400 Bad Request. Code that matched on the message text broke on upgrade, while code that read statusCode kept working:

import {ClientError, ServerError} from '@sanity/client'

try {
  await client.patch('post-123').set({title: 'Updated title'}).commit()
} catch (error) {
  if (error instanceof ClientError) {
    // 4xx: the API rejected the request. Branch on the structured
    // fields, not on the message text.
    console.error(`Request rejected with HTTP ${error.statusCode}`)

    if (error.statusCode === 403) {
      console.error('The token does not have permission to run this mutation')
    }

    if (error.details?.type === 'mutationError') {
      // One entry per mutation that failed, with the reason
      for (const item of error.details.items ?? []) {
        console.error(item.error?.description)
      }
    }

    console.error('Include this trace ID when contacting support:', error.traceId)
  } else if (error instanceof ServerError) {
    // 5xx: the API failed. Reads are safe to retry after a delay. This
    // patch is safe to repeat; a create() without an _id is not.
    console.error(`API error ${error.statusCode}`, error.responseBody)
  } else {
    // A network failure, an aborted request, or a bug in your own code
    throw error
  }
}

A 429 or 5xx on a retryable request reaches your code only after the automatic retries are exhausted. Errors that aren't HTTP responses, such as a DNS failure or an aborted request, aren't instances of either class.

If instanceof isn't reliable in your setup, for example when two copies of @sanity/client are installed, use the isHttpError() type guard instead. It checks the error's shape and narrows to statusCode, message, and response.

Route requests through a proxy

On Node.js, set the proxy option to a proxy URL to send every request the client makes through that proxy, including listen() and live.events() connections. The option is Node.js only:

import {createClient} from '@sanity/client'

export const client = createClient({
  projectId: '',
  dataset: 'production',
  apiVersion: '2026-03-01',
  useCdn: false,
  token: process.env.SANITY_API_TOKEN,
  proxy: 'http://proxy.internal.example.com:3128',
})

// Derive a client for requests that need a different proxy
export const euClient = client.withConfig({
  proxy: 'http://proxy-eu.internal.example.com:3128',
})

The client also honors the HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables on Node.js without any configuration. It reads them once, when it first sets up its HTTP transport, not on every request. Set them before the process starts. Changing them while the process runs has no effect.

In @sanity/client v8 and later, proxy is a client option only. The per-request proxy option from earlier versions is removed. To use different proxies for different requests, derive one client per proxy with withConfig(), as the example shows. Clients are cheap to create.

Reference documentation

For the complete set of mutation options, error types, and client configuration, see the reference documentation for SanityClient and ClientConfig.

Was this page helpful?

sanity.ioAll Systems Operational© Sanity 2026
Change site theme