---
id: performance
title: 'Performance Best Practices'
description: 'Decision-level performance guidance for Agility integrations: what to cache, when to batch, how much to parallelize'
sidebar_label: 'Performance'
sidebar_position: 4
tags: ['guides', 'performance']
---

The single highest-leverage page for Agility performance is
[query size and performance](/guides/query-size-and-performance): no default
page size, default projections, paging discipline, extraction patterns.
Internalize that first. This guide covers the decision-level questions that
remain.

## Cache What Is Stable

| Cache                                        | Why it's safe                                             |
| -------------------------------------------- | ---------------------------------------------------------- |
| `meta.v1` responses                          | The schema changes on admin action, not in daily use — and the endpoint needs no auth |
| Status lists per project/team                | Change on configuration, not on work; re-fetch on a `PrimaryWorkitemCannotHaveInvalidStatus` failure |
| `loc-2.v1` bundles for your UI terms         | Localization changes rarely; unmatched tags echo back safely |

Do **not** cache: query totals (stale immediately), `Rank`/`Order` values
(opaque tokens — [sort, never read](/concepts/attributes#attribute-types-briefly)),
or anything you compare against live data.

## Batch, but Know What It Buys

### What Batching Actually Saves

Batching with the [Bulk API](/apis/asset-bulk) (`~/api/asset`) is worth doing, but
it is worth being precise about why — the common assumption is wrong.

**What it saves:** round trips. One request instead of _n_ means one set of TCP and
TLS handshakes, one authentication, one queue slot, and one latency penalty rather
than _n_ of them. Over a high-latency link that dominates everything else.

**What it does not save:** server-side work. The server still creates each asset,
validates each attribute and fires each rule. A 500-command batch is not
meaningfully cheaper for the server than 500 individual requests.

:::tip Where `api/asset` genuinely is friendlier to the server
Compare it to an Excel import rather than to a loop of REST calls. `api/asset`
**breaks the work into multiple transactions** rather than holding one long
transaction open, so it does not block other users' updates while it runs. An Excel
export or import forces the server to serialize other work behind it; `api/asset`
does not. That is a real, structural advantage — and it is about **concurrency**,
not raw speed.
:::

So: batch to cut round trips and to stay friendly to other users on the instance.
Do not expect the server to finish the same work in a fraction of the time.

One caution when batching queries specifically: the SQL timeout applies
**per query, not per request**, so a request stuffed with several large
queries can run very long in total — and
[long-running queries degrade the instance for everyone](/guides/query-size-and-performance#extracting-at-scale-full-loads-and-delta-sync).

## Parallelism: Modest, Deliberate

When rate limiting is enabled, [configured quota policies](/guides/rate-limiting)
bound throughput alongside shared load.
Your requests share a database with every interactive user on the instance.
A handful of concurrent requests is reasonable; dozens of parallel heavy
queries is an outage with your name on it. For big reads, prefer **fewer,
larger, well-projected pages** over wide fan-out, and handle `429` with
backoff.

## History Is Expensive — Choose the Cheap Shape

Filtered historical projections
(`History[Prior.EffectiveAssetState='Future';…]`) work but cost real
server effort. It is often cheaper to pull the asset's whole
Status/AssetState history in one subquery and detect transitions
client-side — see
[workflows](/concepts/workflows#rollupstate).

## Anti-Patterns, All Verified in the Wild

- **The unbounded list query** — `GET ~/rest-1.v1/Data/Story` pulls every
  open Story in the instance
- **Select-everything projections** — every attribute costs, every relation
  joins, wide projections at large page sizes hang
- **Filtering on names** instead of OIDs — slower, localized, and breaks on
  rename
- **`needTotal` on every page** — the total is a real query of its own;
  [stop on a short page instead](/guides/query-size-and-performance#paging-correctly)
- **Polling `query.v1` on a timer** for change detection — that is what
  [webhooks](/apis/webhooks) are for
- **Treating the SQL timeout as a budget** — a near-timeout query is
  hurting concurrent users already
- **Deeply nested sub-selects on `query.v1` / `api/asset`** — a nested
  relation projection is evaluated as N+1 queries at each level, so cost
  multiplies down the levels rather than adding. A request with a few levels of
  sub-selects over a large root set can pull tens of megabytes and run for
  minutes, monopolizing the web server. Keep nesting shallow, page the root,
  and project only the relations you need

## Checklist

1. Every list query pages, sorted, without `needTotal`
2. Projections name only what the integration consumes
3. Filters compare OIDs and UTC dates
4. `meta.v1` and status lists cached; invalidated on violation surprises
5. Writes batched through `api/asset`; failed commands retried alone
6. Concurrency capped low; extraction runs pinned to one upper time bound

## Related Topics

- [Query size and performance](/guides/query-size-and-performance) — the foundation
- [Bulk API](/apis/asset-bulk)
- [Webhooks](/apis/webhooks)
