Skip to content

Query performance

5 min read · Updated 29 Oct 2026

This page belongs to Data App developer guide. A page with several preloaded tabs mounts every object at once, and each object queries the engine. Without a scheduler, a five-tab app sends 40–90 queries on load. One click then re-queries every object on every tab, often 50–200 queries. The browser queues them and the engine slows under the burst. Each query looks slow, but most of the time is spent waiting.

These figures are indicative. They were measured in October 2026 on a demo tenant with an X-Small Snowflake warehouse, @astrato-io/visualizations 0.5.2 and @astrato/sdk 0.4.0. Snowflake answered most queries in about 80 ms from its result cache, while the median engine round trip around each query was 0.65–1 s, and higher under tenant load. App code did not reduce that round trip. It can reduce browser queueing and stop sending queries that are not needed.

Wrap engine.getData once, before the runtime mounts anything, so chart queries and the app’s own KPI and headline queries pass through the same code.

  • Gate. Allow 2–4 queries in flight. More than that raises the engine’s per-query time without finishing sooner. Measure 2, 3 and 4 for your app and keep the lowest p90.
  • Visible tab first. Give queries for objects on the visible tab priority. Preload hidden tabs at low priority after the visible tab has settled; do not skip preloading.
  • Cache and dedupe. Key results by the full request body, which includes the selection filters. Two identical requests in flight share one call. A repeated selection state is answered from memory. Hand out copies, so a caller that sorts rows does not change the cache. Drop failed requests so they can be retried.
  • Merge only like with like. Single-row KPI reads with the same filters can share one query when their measures come from the same view. Merging measures from different views, or wide multi-family queries, made the engine slower (2–3 s against about 0.7 s each when sent separately). Measure before keeping a merge.
  • Warm-up. Send one trivial query, such as one measure with no dimensions, while the splash shows. The first queries of a new session are otherwise slow (2–3.5 s).
  • Load low-value data last. Load filter option lists and other non-visible lookups after the visible objects have rendered.

With the package versions above, the chart runtime does not tell getData which object is asking. To map a request to its tile, match the request’s field keys against each tile’s definition.

Re-query only the visible tab on a selection change

Section titled “Re-query only the visible tab on a selection change”

One selection group shared by every tab makes every object on every tab re-query on each change. Give each tab its own copy of each group instead:

  1. Bind each object to its tab’s copy, for example main@overview, main@detail.
  2. Hold the wanted selection state in one place in the app: chips, dropdowns, Clear all and the app’s own queries read and write that state, not a group.
  3. On a change, write the state into the visible tab’s groups only, with one setNewSelectionState per group. Skip any group already in that state.
  4. When a tab is opened, sync its groups if the state changed since it was last shown.
  5. Before mounting, write the default state into every tab’s groups, so preloaded tabs never run an unfiltered first query.
  6. A chart click lands as a pending selection in its tab’s copy. Move it into the app’s state and clear it from the copy, so the click filters every tab. Do it outside the subscriber, for example in a zero-delay timer, and ignore the emissions your own writes cause.

Measured: a row click went from 136 queries to 21–27, and a tab switch with no selection change fires none.

Common sources of double work:

  • Remove then add. Changing a selection with removeSelection followed by selectValues emits two changes, and every object queries twice. Write the whole state with setNewSelectionState.
  • Mirrored groups. Copying main-group selections into a secondary group field by field, even when the values have not changed, re-queries that group each time. Compare first.
  • Pending and applied. Refreshing app text on both the pending and the applied emission runs every KPI query twice. Refresh on applied state only.
  • Click hand-offs. A click that the app turns into something else, such as opening a panel or focusing a record, still lands as a selection first. Intercept it before it becomes a selection, or the whole page re-renders from cache.
  • Starved debounce. A debounce that every emission pushes back can starve during a burst, and the redraw never runs. Redraw once per distinct selection state instead.
  • App text for hidden tabs. Compute KPI and headline text only for the visible tab. Compute the others when their tab opens, from the cache when the state is unchanged.

Time every engine request from the browser, and split each one into queue time and server time (Playwright request.timing(): queue is requestStart, server is responseStart - requestStart). Report n, p50, p90, max and the count over 1 s for:

  • Load: from navigation until the visible objects have rendered.
  • Each interaction: a table row click, a filter change and each tab switch, measured separately. Choose interactions that actually change a selection in the app under test; a user picker or a page with no table measures nothing.
  • Settle time: from the interaction until the last query it caused has finished. An in-app gate holds queries before the browser sees them, so per-request timings alone hide that wait.

Measure one app at a time, with no other measurement running against the same tenant. Concurrent runs raised every app’s server time by 30–50%. Repeat each measurement: the same code varied from 0.63 s to 0.86 s median server time between consecutive runs.

When queue time is 0 and per-query server time is still above target, the remaining time is the engine round trip. To investigate it, compare the engine’s own figures with the browser’s server time: engine.getLastQueryMetadata(objectId) reports duration and statement, engine.getQueryInsights reports cache use. Then match the statement in the warehouse query history. The options left are fewer distinct queries per tab, or a pre-aggregated governed view for the heaviest tab.