In Retool, trigger queries from JS Queries using await queryName.trigger(). Without await, the script continues before the query finishes — stale data results. For independent queries, await Promise.all([q1.trigger(), q2.trigger()]) runs them in parallel. Pass runtime parameters using additionalScope: { paramName: value }. The same async gotcha applies to state variable setValue() — always await it.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Advanced |
| Time required | 20-25 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Async/Await Patterns for Retool Query Orchestration
Retool's query execution is inherently asynchronous — when you call query.trigger(), it returns a Promise that resolves when the query completes. Understanding this async nature is essential for building reliable workflows where actions must happen in a specific order or where multiple queries need to complete before proceeding.
The most common bugs in Retool JS Queries come from missing await keywords. Without await, a query fires and the script immediately continues — reading the query's data property returns the stale result from the previous run, not the new data.
This tutorial covers the full async query toolkit: single query triggering with await, parallel execution with Promise.all(), passing parameters with additionalScope, and handling the async gotchas that trip up Retool developers.
Prerequisites
- A Retool account with an app containing multiple queries
- Solid JavaScript async/await knowledge (Promises, async functions, try/catch)
- Understanding of JS Queries and how to create them in Retool
- Familiarity with Retool state variables and setValue()
Step-by-step guide
Trigger a query asynchronously with await
In a JS Query, call await queryName.trigger() to execute another query and wait for it to complete before continuing. The trigger() method returns a Promise that resolves with the query's result object. Without await, the script continues immediately and queryName.data still contains the previous run's result (or null on first run). Always await mutation queries (INSERT/UPDATE/DELETE) before refreshing data queries.
1// WITHOUT await — race condition2updateRecord.trigger(); // Fires and forgets3const refreshed = await getRecords.trigger(); // May run BEFORE update finishes!4console.log(getRecords.data); // May return stale data56// WITH await — correct order guaranteed7await updateRecord.trigger(); // Wait for update to complete8await getRecords.trigger(); // Then refresh — always stale-free9console.log(getRecords.data); // Fresh data from the completed query1011// Access result directly from the trigger() return value12const result = await createRecord.trigger();13console.log('Created record:', result); // The query's result dataExpected result: The update query completes before the refresh query runs. No stale data in the UI.
Run independent queries in parallel with Promise.all()
When you need to fetch data from multiple independent sources, Promise.all() runs all queries simultaneously. The total wait time equals the slowest query's duration, not the sum of all durations. Promise.all() rejects as soon as any single Promise rejects — use Promise.allSettled() if you want to get all results even when some fail.
1// SEQUENTIAL — slow (3 queries × ~500ms each = ~1500ms)2await getCustomers.trigger();3await getOrders.trigger();4await getProducts.trigger();56// PARALLEL — fast (~500ms total if all take ~500ms)7await Promise.all([8 getCustomers.trigger(),9 getOrders.trigger(),10 getProducts.trigger()11]);1213// All data is now available:14console.log('Customers:', getCustomers.data?.length);15console.log('Orders:', getOrders.data?.length);16console.log('Products:', getProducts.data?.length);1718// With Promise.allSettled() — never rejects, even if one fails19const results = await Promise.allSettled([20 getCustomers.trigger(),21 getOrders.trigger(),22 getProducts.trigger()23]);2425results.forEach((result, i) => {26 if (result.status === 'rejected') {27 console.error(`Query ${i} failed:`, result.reason);28 }29});Expected result: All three queries execute simultaneously. The script waits for all to complete before proceeding, with total time equal to the slowest query.
Pass runtime parameters with additionalScope
SQL and REST queries use {{ }} bindings to read component values. When you trigger a query from a JS Query, you can override those bindings at runtime using additionalScope. Pass an object where each key matches a {{ }} variable name in the target query's body. This is essential for loops where you trigger the same query with different values per iteration.
1// SQL Query: 'updateOrderStatus'2// SQL: UPDATE orders SET status = {{ newStatus }}, updated_at = NOW()3// WHERE id = {{ orderId }}45// JS Query: batchUpdateOrders6const selectedRows = table1.selectedRows;7const targetStatus = newStatusSelect.value;89for (const row of selectedRows) {10 // additionalScope injects runtime values into the SQL query's {{ }} bindings11 await updateOrderStatus.trigger({12 additionalScope: {13 orderId: row.id, // Overrides {{ orderId }}14 newStatus: targetStatus // Overrides {{ newStatus }}15 }16 });17}1819// Refresh the table after all updates20await getOrders.trigger();2122utils.showNotification({23 title: `${selectedRows.length} orders updated to ${targetStatus}`,24 notificationType: 'success'25});Expected result: Each row is updated with the correct ID from the loop iteration, using the same SQL query with different parameters each time.
Handle the setValue() async gotcha
State variable setValue() is also asynchronous — like query.trigger(). If you call setValue() without await and then immediately read the variable's value, you get the old value. This is one of the most common Retool bugs. Always await setValue() when you need to read the updated value in the same function.
1// THE GOTCHA — reads old value2isLoading.setValue(true); // Does NOT wait3console.log(isLoading.value); // Still false!45// CORRECT — with await6await isLoading.setValue(true); // Waits for update7console.log(isLoading.value); // true ✓89// Practical example: loading indicator10await isLoading.setValue(true);11try {12 await fetchData.trigger();13 await processResult.trigger();14} finally {15 await isLoading.setValue(false); // Always clears loading16}1718// Another gotcha: setIn() is also async19await formData.setIn(['email'], emailInput.value);20await formData.setIn(['name'], nameInput.value);21console.log(formData.value.email); // Correct — after awaitExpected result: State variables reflect their updated values immediately after await setValue() completes.
Read query results after triggering
After await query.trigger() completes, the query's .data property holds the fresh result. You can also capture the return value of trigger() directly. Both approaches give you the same data. Use direct capture when you want to work with multiple query results without referencing query.data repeatedly.
1// Option 1: Read query.data after await2await getCustomer.trigger({3 additionalScope: { id: table1.selectedRow.data.id }4});5const customer = getCustomer.data[0]; // Read from query.data6console.log('Customer:', customer.name);78// Option 2: Capture return value9const result = await getCustomer.trigger({10 additionalScope: { id: table1.selectedRow.data.id }11});12const customer2 = result[0]; // Same data, captured directly1314// Option 3: Parallel with named results15const [customerResult, ordersResult] = await Promise.all([16 getCustomer.trigger({ additionalScope: { id: selectedId } }),17 getCustomerOrders.trigger({ additionalScope: { customerId: selectedId } })18]);1920const customerName = customerResult?.[0]?.name;21const orderCount = ordersResult?.length;22return { customerName, orderCount };Expected result: Query results are available immediately after the await completes, either from the query's .data property or from the trigger() return value.
Complete working example
1// Complex async workflow demonstrating all patterns:2// - Sequential awaits for dependent operations3// - Promise.all() for independent parallel fetches4// - additionalScope for parameterized triggers5// - setValue() async handling6// - try/catch/finally for error recovery78const customerId = table1.selectedRow.data?.id;910if (!customerId) {11 utils.showNotification({ title: 'Select a customer first', notificationType: 'warning' });12 return;13}1415// Step 1: Set loading state (async, must await)16await isLoading.setValue(true);17await loadingMessage.setValue('Loading customer data...');1819try {20 // Step 2: Fetch customer + recent orders in PARALLEL21 const [customerData, ordersData] = await Promise.all([22 getCustomer.trigger({ additionalScope: { id: customerId } }),23 getCustomerOrders.trigger({ additionalScope: { customerId, limit: 10 } })24 ]);25 26 const customer = customerData?.[0];27 if (!customer) {28 throw new Error(`Customer ${customerId} not found`);29 }30 31 // Step 3: Based on customer status, fetch additional data SEQUENTIALLY32 if (customer.status === 'vip') {33 await loadingMessage.setValue('Loading VIP benefits...');34 // This MUST run after we know it is a VIP — sequential is correct here35 await getVipBenefits.trigger({ additionalScope: { customerId } });36 }37 38 // Step 4: Compute and return enriched data39 const enriched = {40 ...customer,41 orderCount: ordersData?.length || 0,42 totalSpent: ordersData?.reduce((sum, o) => sum + o.amount, 0) || 0,43 recentOrders: ordersData?.slice(0, 5) || [],44 vipBenefits: customer.status === 'vip' ? getVipBenefits.data : null45 };46 47 // Step 5: Update state with results48 await selectedCustomerData.setValue(enriched);49 50 return enriched;51 52} catch (err) {53 console.error('Customer load failed:', err);54 utils.showNotification({55 title: 'Load failed',56 description: err.message,57 notificationType: 'error'58 });59 return null;60 61} finally {62 // Always clears loading state — even on error63 await isLoading.setValue(false);64 await loadingMessage.setValue('');65}Common mistakes
Why it's a problem: Calling query.trigger() without await and then immediately reading query.data
How to avoid: Add await before query.trigger(). Without it, the script continues before the query completes and query.data holds stale data.
Why it's a problem: Using Promise.all() for queries that depend on each other's results
How to avoid: Promise.all() runs queries simultaneously — use it only when queries are fully independent. If query B needs data from query A's result, they must be sequential with await.
Why it's a problem: Reading a state variable immediately after setValue() without await
How to avoid: setValue() is asynchronous. Use await state1.setValue(value) before reading state1.value in the same execution path.
Why it's a problem: Forgetting that event handlers on components run in parallel — two handlers can trigger overlapping async operations
How to avoid: Consolidate sequential operations into a single JS Query that chains steps with await, rather than relying on multiple parallel event handlers.
Best practices
- Always await query.trigger() before reading the query's .data or proceeding with dependent logic.
- Use Promise.all() for independent queries — it is significantly faster than sequential awaits and reduces total load time.
- Always await state variable setValue() calls when you need to read the updated value in the same function.
- Use Promise.allSettled() instead of Promise.all() when some queries are non-critical and you want the workflow to continue even if they fail.
- Pass runtime parameters via additionalScope rather than relying on live component values for queries triggered in loops — component values can change mid-loop.
- Wrap long async workflows in try/catch/finally — finally ensures loading state always clears, preventing the UI from getting stuck.
- Log query trigger calls with console.log checkpoints to make async debugging easier when the order of operations is unclear.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a Retool app where clicking a table row should: (1) load the customer's full details, (2) simultaneously load their order history and payment history in parallel, (3) then — only if they are a VIP — load their VIP benefits, (4) update a state variable with all the combined data, and (5) show a loading indicator throughout. Show me the JS Query with proper async/await, Promise.all() for the parallel fetches, additionalScope for parameterized triggers, and setValue() async handling.
Write a Retool JS Query that runs three queries: (1) updateRecord with await, (2) then runs getRecords and getStats in PARALLEL using Promise.all(), (3) passes the selected row's ID via additionalScope to updateRecord, (4) wraps everything in try/catch with a finally block to clear isLoading state variable, and (5) shows a success notification after all queries complete.
Frequently asked questions
What does await queryName.trigger() return in Retool?
It returns the query's result data — the same data available via queryName.data after the query runs. For SQL queries, this is typically an array of row objects. For REST queries, it is the parsed response body. You can capture it: const result = await query.trigger() or just await query.trigger() and read queryName.data.
Can I run the same query multiple times in a loop with different parameters in Retool?
Yes. Use a for...of loop with await query.trigger({ additionalScope: { param: value } }) for each iteration. Each loop iteration waits for the previous trigger to complete before starting the next. For high-volume batch operations, consider using Promise.all() with a batch of triggers, but be aware of database connection limits.
What is the difference between query.trigger() and query.run() in Retool?
In current Retool versions, query.trigger() is the standard method and returns a Promise. query.run() is an older alias that behaves similarly. Use trigger() for consistency with Retool's current documentation. Both are async and should be awaited.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation