| name | performance |
| description | MUST be used when fixing Flows app performance — re-renders, query patterns, pagination, unbounded fetches, LLM-over-query-results, bundles, memory leaks. Measure before and after. Triggers: performance, slow, laggy, optimize, re-render, bundle size, CDF query, virtualize, chat completions, LLM cost. |
| allowed-tools | Read, Glob, Grep, Shell, Write |
| metadata | {"argument-hint":"[file, component, or area to optimize — e.g. 'src/components/AssetTable.tsx']"} |
Performance Fix
Systematically find and fix performance issues in $ARGUMENTS (or the whole app if no argument is given). Always measure first — never optimize blindly.
Step 1 — Measure baseline before touching anything
Run the production build and capture metrics before making any changes:
pnpm run build
pnpm run preview
Open the app in Chrome and capture:
- Lighthouse score (Performance tab → Run audit)
- React Profiler (React DevTools → Profiler → Record an interaction)
- Note the components with the longest render times and highest render counts
Record baseline numbers. Every fix must be measured against these.
Step 2 — Find and fix unnecessary re-renders
Read the component tree (start from src/App.tsx) and search for these patterns:
grep -rn --include="*.tsx" \
-E "value=\{\{|onClick=\{\(\)" src/
For each instance found, apply the fix directly:
Inline object/array creation in JSX → wrap with useMemo:
<Chart options={{ color: "red" }} />
const chartOptions = useMemo(() => ({ color: "red" }), []);
<Chart options={chartOptions} />
Event handlers recreated on every render → wrap with useCallback:
<Button onClick={() => doSomething(id)} />
const handleClick = useCallback(() => doSomething(id), [id]);
<Button onClick={handleClick} />
Context that changes on every render → memoize the context value:
<MyContext.Provider value={{ user, sdk }}>
const ctxValue = useMemo(() => ({ user, sdk }), [user, sdk]);
<MyContext.Provider value={ctxValue}>
Apply React.memo to pure presentational components that receive stable props. Do NOT wrap every component — only those confirmed to re-render unnecessarily via the Profiler.
Step 3 — Find and fix DMS query patterns
For read-heavy workloads, prefer APIs that hit the search/Elasticsearch path (query or search on instances) rather than list paths that stress Postgres.
grep -rn --include="*.ts" --include="*.tsx" -E "instances\.(list|search|query|aggregate|retrieve)" src/
grep -rn --include="*.ts" --include="*.tsx" -E "\.(assets|timeseries|events|files|sequences|relationships)\.(list|search|retrieve)" src/
For each instances.list call in a read-heavy path (e.g. populating a table, dropdown, or search results), rewrite it to use instances.query with the equivalent filter. Preserve the existing filter logic but express it in the query API format:
const result = await client.instances.list({
instanceType: "node",
filter: { equals: { property: ["node", "space"], value: "my-space" } },
limit: 100,
});
const result = await client.instances.query({
with: {
nodes: {
nodes: {
filter: { equals: { property: ["node", "space"], value: "my-space" } },
},
limit: 100,
},
},
select: {
nodes: {},
},
});
| API used | When it's correct | When to rewrite |
|---|
instances.query | Read with filters that map to Elasticsearch (text, equals, range) | — |
instances.search | Full-text or fuzzy search | — |
instances.list | Writing, syncing, or need for semantics not available on query/search | Rewrite to instances.query if used for read-heavy UI display |
instances.retrieve | Fetching by known external IDs | — |
instances.aggregate | Counts, histograms | — |
For deeper rationale on search vs relational paths, cardinality, and materialization tradeoffs, consult the semantic-knowledge/ directory if available in the workspace.
Hard gate — LLM over query results
grep -rn --include="*.ts" --include="*.tsx" -E "chat\.completions|agents/chat|useAtlasChat|openai|anthropic" src/
Do not map completions over DMS rows. Fix: one sendAgentMessage or agent resource (integrate-fusion-agent). If per-item completions remain: 5 / ceiling 50, cache by space:externalId:lastUpdatedTime, user-initiated only.
Step 4 — Find and fix client-side filtering (move to server-side)
Filters, limits, and projections must be applied in the API request — not by downloading large result sets and filtering in the browser.
grep -rn --include="*.ts" --include="*.tsx" -B 5 "\.filter(" src/ | grep -B 5 "data\|items\|result\|response\|nodes"
grep -rn --include="*.ts" --include="*.tsx" -E "\.(map|reduce|find|some|every)\(" src/hooks/ src/services/ src/api/
For each client-side filter pattern, move the filter logic into the SDK call's filter parameter and remove the .filter() call:
const result = await client.instances.query({ ... });
const activeNodes = result.items.nodes.filter(n => n.properties.status === "active");
const result = await client.instances.query({
with: {
nodes: {
nodes: {
filter: {
and: [
existingFilters,
{ equals: { property: ["mySpace", "myView/v1", "status"], value: "active" } },
],
},
},
limit: 100,
},
},
select: { nodes: {} },
});
const activeNodes = result.items.nodes;
| Issue | Fix |
|---|
.filter() after SDK call on full result set | Move the filter into the API request's filter parameter and delete the .filter() |
No properties selection in DMS query | Add a sources or properties parameter to fetch only needed fields |
| Fetching all items then rendering a subset | Add limit and filter to the API call to fetch only what's displayed |
| Client-side text search on fetched array | Replace with the SDK's search endpoint |
Hard rule: If the API supports a filter for the criterion being applied client-side, move it server-side now. Client-side filtering is acceptable only for trivial local state (e.g. filtering a cached list of 10 user preferences). If the API does not support the exact filter, add a code comment explaining why client-side filtering is necessary.
Step 5 — Find and fix CDF data fetching and pagination
Read all CDF SDK calls (search for sdk., client., useQuery, useCogniteClient).
grep -rn --include="*.ts" --include="*.tsx" -E "(nextCursor|cursor|hasNextPage|fetchNextPage|offset|skip|page)" src/
grep -rn --include="*.ts" --include="*.tsx" -B 3 -A 3 "while.*cursor\|while.*hasMore\|while.*nextPage" src/
For each call, find the issue and apply the fix:
| Issue | Fix to apply |
|---|
No limit set | Add limit: 100 (or the actual page size needed) to the SDK call |
| Fetching all properties | Add a properties filter to select only required fields |
| Fetching on every render | Move inside useQuery/useMemo with a stable dependency array |
| Sequential requests that could be parallel | Rewrite to Promise.all or batched SDK methods |
Missing limit parameter | Add explicit limit matching the UI's page size (e.g. 25, 50, 100) |
| Offset-based pagination for large datasets | Replace with cursor-based pagination using nextCursor from the response |
| "Fetch all" loop (exhausts cursors up front) | Replace with on-demand pagination using TanStack Query's useInfiniteQuery |
Fixing fetch-all loops — replace the while loop with useInfiniteQuery:
let allItems = [];
let cursor = undefined;
while (true) {
const result = await client.instances.list({ limit: 1000, cursor });
allItems.push(...result.items);
if (!result.nextCursor) break;
cursor = result.nextCursor;
}
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["instances", filters],
queryFn: ({ pageParam }) =>
client.instances.list({ limit: 100, cursor: pageParam, ...filters }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
staleTime: 30_000,
});
Fixing offset-based pagination — switch to cursor-based:
const result = await client.instances.list({ limit: 100, offset: page * 100 });
const result = await client.instances.list({ limit: 100, cursor: nextCursor });
Step 6 — Find and fix excessive API call rates
grep -rn --include="*.tsx" --include="*.ts" -E "onChange|onInput|onSearch|onFilter" src/ | grep -i "search\|filter\|query"
grep -rn --include="*.ts" --include="*.tsx" -i -E "debounce|useDebouncedValue|useDebounce" src/
grep -rn --include="*.ts" --include="*.tsx" -E "setInterval|refetchInterval|pollingInterval|refetchOnWindowFocus" src/
grep -rn --include="*.ts" --include="*.tsx" -E "staleTime|cacheTime|gcTime|refetchOnMount|refetchOnWindowFocus" src/
For each issue found, apply the fix:
Search inputs that fire on every keystroke → add debounce with 300ms delay:
const [search, setSearch] = useState("");
const { data } = useQuery({ queryKey: ["search", search], queryFn: () => api.search(search) });
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 300);
const { data } = useQuery({
queryKey: ["search", debouncedSearch],
queryFn: () => api.search(debouncedSearch),
enabled: debouncedSearch.length > 0,
});
useQuery calls without staleTime → add appropriate staleTime:
useQuery({ queryKey: ["data"], queryFn: fetchData });
useQuery({ queryKey: ["data"], queryFn: fetchData, staleTime: 30_000 });
Duplicate parallel identical requests → lift the query to a shared hook:
export function useAssets() {
return useQuery({ queryKey: ["assets"], queryFn: fetchAssets, staleTime: 30_000 });
}
| Issue | Fix to apply |