Performance best practices
Best practices for building performant apps.
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/OFFSETor 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.
- Best, in the data source: Apply
- 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.
Push work to the data source
Filtering, sorting, and aggregating should happen in the database, not in function code or the browser.
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
WHEREandORDER BYclauses. 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.
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.
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.
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.alltoo. 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.
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.
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.
| Layer | Typical fixes | Who owns it |
|---|---|---|
| App | Pagination, triggers, parallelization, payload shaping, caching in state | The builder, by prompting the agent or editing code |
| Resource | Connection configuration, environments, pointing read-heavy apps at a read replica | Retool admins, in resource settings |
| Data source | Indexes, views and materialized views for heavy aggregations, warehouse sizing, stored procedures for deep joins | Your 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":
State data volume up front
Stating the expected volume changes the architecture the agent chooses:
Specify trigger behavior explicitly
Say when each function should run:
State security constraints
Confirm the enforcement happens in the function's query, not just in what the UI displays:
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.