Skip to main content

Performance best practices

Use the best practices in the following sections to improve the performance of your app and its functions.

Paginate large datasets

Large tables are the most common performance pitfall. Fetch only what the user can see, and let the data source do the limiting.

  • Paginate as close to the data as possible. Where you trim the dataset decides how far every row travels:
    • Best, in the data source: Apply LIMIT/OFFSET or cursor conditions in the query itself, passing page size and page number (or a cursor) as typed function parameters. Only one page ever leaves the database.
    • Sometimes fine, in the function: If the resource can't paginate, slicing the result in function code keeps extra rows out of the browser. The full dataset still crosses from the data source into Retool, so this strategy only applies for moderate result sets.
    • Worst, in the browser: Never fetch everything and paginate client-side in React. Every row is transferred over the network and held in memory.
  • Select only the columns the table displays. For example, avoid SELECT *, especially on wide tables or tables with large text or JSON columns.
  • Fetch heavy detail fields (long descriptions, JSON blobs, attachments) in a separate function that runs only when the user opens a record's detail view.
This table will have ~100k rows. Use server-side pagination: fetch 25 rows per page ordered by created_at descending, with the page number as a function parameter. Only select the six columns the table displays.
Move the notes and raw_payload fields out of the list function. Fetch them in a separate function that runs when a row's detail panel opens.

Push work to the data source

Filtering, sorting, and aggregating should happen in the database, not in function code or the browser.

note

Ask the LLM to use query pushdown and keep the application layer thin. Related conventions include thin views in Django and skinny controllers in Ruby on Rails. These terms signal that data-intensive operations should be delegated to the database or resource layer rather than performed in UI or request-handling code.

  • Pass filter and sort values as function parameters and apply them in WHERE and ORDER BY clauses. Don't fetch everything and filter the results in TypeScript.
  • Use database aggregation (COUNT, SUM, GROUP BY) for metrics and summaries rather than computing them from raw rows.
  • Keep unavoidable transformation logic inside the function, where it runs server-side, so the browser receives small, ready-to-render results.
  • Because function parameters are strongly typed and queries are parameterized, values are passed safely rather than concatenated into SQL. Keep it that way: if you edit code directly, never build SQL strings from user input.
The status filter currently fetches all orders and filters them in code. Pass the selected status as a typed parameter to the function and filter in the SQL WHERE clause instead.

Eliminate redundant fetches

Each piece of data should be fetched by one function call and shared across the components that need it via React state or props, not fetched separately by each component.

  • If the Debug console or a function's run history shows the same function firing multiple times per interaction, ask the agent to consolidate the triggers.
  • Watch for re-fetch loops: a trigger() call inside an effect whose dependencies change on every render fires continuously. Monitor the Timeline tab in the Debug console for function runs that continue while the app sits idle.
The customer summary and the customer table both call getCustomers separately. Fetch once on load and share the result with both components.
getOrders shows 40 runs in its history this session, but I only changed the filter twice. Find and fix whatever is re-triggering it.

Control when functions run

Running too many functions on page load delays the time until users can interact with the app.

  • Load only the data needed for the initial view. Defer everything else until the user asks for it.
  • Don't fetch data for hidden UI. Tabs, modals, drawers, and detail panels should trigger their functions when opened, not on app load.
  • Be explicit with the agent about trigger behavior for every function: on load, on click, or when a specific value changes. In code, this corresponds to where trigger() is called.
  • Debounce search and filter inputs (300-500ms) so a function runs after the user stops typing, not on every keystroke.
Only run getAuditLog when the History tab is opened, not on app load.
Debounce the search input so searchCustomers runs 400ms after the user stops typing.

Parallelize independent functions

When one function's input doesn't depend on another function's output, they should run concurrently.

  • Use the Timeline tab in the Debug console to spot candidates: sequential stair-step requests where the later request doesn't need the earlier one's data. A common example is a page that loads customers, open tickets, and team members back-to-back when all three are independent.
  • In frontend code, issue independent function calls concurrently (for example, with Promise.all) rather than awaiting them one after another.
  • The same applies inside a function. If one function makes several independent resource calls, run them with Promise.all too. Otherwise the database round trips happen sequentially even though the browser made a single request.
  • Truly dependent steps, where step two needs step one's result, can be composed into a single function so the round trips between browser and backend collapse into one. This only removes the browser-to-Retool hops; the resource calls inside the function still run one after another unless they're independent and parallelized.
In the Timeline tab, getCustomers, getTickets, and getTeam run sequentially on load but don't depend on each other. Run them in parallel.
Combine the lookup of the customer ID and the fetch of their orders into one function so the app makes a single call.

Cache stable reference data

Data that rarely changes, such as dropdown options, categories, country lists, and role definitions, shouldn't be re-fetched on every interaction.

  • Fetch reference data once on app load and hold it in app state for the rest of the session.
  • For data that changes occasionally, refresh on a long interval (for example, every 10-15 minutes) instead of on every render or interaction.
  • For read-only reference data, you can also ask the agent to enable stale-while-revalidate caching on the function's hook (cachePolicy: 'stale-while-revalidate'), which serves cached results immediately while refreshing in the background. This applies to read-only functions only.
  • For expensive client-side computation over already-loaded data, memoize the result so it isn't recomputed on every render.
Load the department list once when the app loads and reuse it for all dropdowns. Don't re-fetch it during the session.

Keep response payloads small

Row counts aren't the only cost; the amount of data returned in each row also matters. Large responses slow network transfer and browser rendering even when the query itself is fast.

  • Return only the fields the UI consumes. Shape the response inside the function.
  • Avoid returning Base64-encoded files, images, or large nested JSON in list responses. Return references instead, and load heavy content on demand.
  • For large read-only result sets that the UI can render incrementally, ask the agent to use a streaming resource method. Streaming improves time-to-first-byte and avoids buffering the full response server-side. It applies when the function doesn't need to transform the result.

Match fixes to the right layer

Some performance work doesn't belong in the app at all. Route each fix to the layer that owns it.

LayerTypical fixesWho owns it
AppPagination, triggers, parallelization, payload shaping, caching in stateThe builder, by prompting the agent or editing code
ResourceConnection configuration, environments, pointing read-heavy apps at a read replicaRetool admins, in resource settings
Data sourceIndexes, views and materialized views for heavy aggregations, warehouse sizing, stored procedures for deep joinsYour database administrator or data platform team

If your latency triangulation shows the database is the bottleneck, bring the numbers to the owning team rather than trying to prompt around it. A missing index fixed at the source benefits every app and workflow that touches that table.

Prompting the agent for performance

The agent fixes performance issues best when your prompt includes evidence you've gathered.

Ground prompts in numbers

A grounded prompt gets a better result than a vague one like "the orders page is slow":

The getOrders function takes 4 seconds in the playground and returns 2MB

State data volume up front

Stating the expected volume changes the architecture the agent chooses:

This table will grow to ~500k rows

Specify trigger behavior explicitly

Say when each function should run:

Run only when the Submit button is clicked, not on load.

State security constraints

Confirm the enforcement happens in the function's query, not just in what the UI displays:

This function should only return records belonging to the signed-in user.

Scope the change

Name the specific function or component to change, and say what should stay untouched, so a performance fix doesn't ripple into unrelated parts of the app.

Verify after each fix

Re-run the playground timing or reload with the Timeline tab open, and compare against your baseline before moving to the next issue.