Skip to main content
RapidDev - Software Development Agency
retool-tutorial

How to Use Asynchronous Queries in Retool

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.

What you'll learn

  • How to trigger queries asynchronously with await queryName.trigger()
  • How to run multiple queries in parallel with Promise.all()
  • How to pass runtime parameters to triggered queries using additionalScope
  • The critical setValue() async gotcha and how to avoid stale state reads
  • How to handle query results after trigger() resolves
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced8 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
TL;DR

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.

Quick facts about this guide
FactValue
ToolRetool
DifficultyAdvanced
Time required20-25 min
CompatibilityRetool Cloud and Self-hosted
Last updatedMarch 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

1

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.

typescript
1// WITHOUT await — race condition
2updateRecord.trigger(); // Fires and forgets
3const refreshed = await getRecords.trigger(); // May run BEFORE update finishes!
4console.log(getRecords.data); // May return stale data
5
6// WITH await — correct order guaranteed
7await updateRecord.trigger(); // Wait for update to complete
8await getRecords.trigger(); // Then refresh — always stale-free
9console.log(getRecords.data); // Fresh data from the completed query
10
11// Access result directly from the trigger() return value
12const result = await createRecord.trigger();
13console.log('Created record:', result); // The query's result data

Expected result: The update query completes before the refresh query runs. No stale data in the UI.

2

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.

typescript
1// SEQUENTIAL — slow (3 queries × ~500ms each = ~1500ms)
2await getCustomers.trigger();
3await getOrders.trigger();
4await getProducts.trigger();
5
6// PARALLEL — fast (~500ms total if all take ~500ms)
7await Promise.all([
8 getCustomers.trigger(),
9 getOrders.trigger(),
10 getProducts.trigger()
11]);
12
13// All data is now available:
14console.log('Customers:', getCustomers.data?.length);
15console.log('Orders:', getOrders.data?.length);
16console.log('Products:', getProducts.data?.length);
17
18// With Promise.allSettled() — never rejects, even if one fails
19const results = await Promise.allSettled([
20 getCustomers.trigger(),
21 getOrders.trigger(),
22 getProducts.trigger()
23]);
24
25results.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.

3

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.

typescript
1// SQL Query: 'updateOrderStatus'
2// SQL: UPDATE orders SET status = {{ newStatus }}, updated_at = NOW()
3// WHERE id = {{ orderId }}
4
5// JS Query: batchUpdateOrders
6const selectedRows = table1.selectedRows;
7const targetStatus = newStatusSelect.value;
8
9for (const row of selectedRows) {
10 // additionalScope injects runtime values into the SQL query's {{ }} bindings
11 await updateOrderStatus.trigger({
12 additionalScope: {
13 orderId: row.id, // Overrides {{ orderId }}
14 newStatus: targetStatus // Overrides {{ newStatus }}
15 }
16 });
17}
18
19// Refresh the table after all updates
20await getOrders.trigger();
21
22utils.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.

4

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.

typescript
1// THE GOTCHA — reads old value
2isLoading.setValue(true); // Does NOT wait
3console.log(isLoading.value); // Still false!
4
5// CORRECT — with await
6await isLoading.setValue(true); // Waits for update
7console.log(isLoading.value); // true ✓
8
9// Practical example: loading indicator
10await isLoading.setValue(true);
11try {
12 await fetchData.trigger();
13 await processResult.trigger();
14} finally {
15 await isLoading.setValue(false); // Always clears loading
16}
17
18// Another gotcha: setIn() is also async
19await formData.setIn(['email'], emailInput.value);
20await formData.setIn(['name'], nameInput.value);
21console.log(formData.value.email); // Correct — after await

Expected result: State variables reflect their updated values immediately after await setValue() completes.

5

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.

typescript
1// Option 1: Read query.data after await
2await getCustomer.trigger({
3 additionalScope: { id: table1.selectedRow.data.id }
4});
5const customer = getCustomer.data[0]; // Read from query.data
6console.log('Customer:', customer.name);
7
8// Option 2: Capture return value
9const result = await getCustomer.trigger({
10 additionalScope: { id: table1.selectedRow.data.id }
11});
12const customer2 = result[0]; // Same data, captured directly
13
14// Option 3: Parallel with named results
15const [customerResult, ordersResult] = await Promise.all([
16 getCustomer.trigger({ additionalScope: { id: selectedId } }),
17 getCustomerOrders.trigger({ additionalScope: { customerId: selectedId } })
18]);
19
20const 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

JS Query: complexAsyncWorkflow
1// Complex async workflow demonstrating all patterns:
2// - Sequential awaits for dependent operations
3// - Promise.all() for independent parallel fetches
4// - additionalScope for parameterized triggers
5// - setValue() async handling
6// - try/catch/finally for error recovery
7
8const customerId = table1.selectedRow.data?.id;
9
10if (!customerId) {
11 utils.showNotification({ title: 'Select a customer first', notificationType: 'warning' });
12 return;
13}
14
15// Step 1: Set loading state (async, must await)
16await isLoading.setValue(true);
17await loadingMessage.setValue('Loading customer data...');
18
19try {
20 // Step 2: Fetch customer + recent orders in PARALLEL
21 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 SEQUENTIALLY
32 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 here
35 await getVipBenefits.trigger({ additionalScope: { customerId } });
36 }
37
38 // Step 4: Compute and return enriched data
39 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 : null
45 };
46
47 // Step 5: Update state with results
48 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 error
63 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.

ChatGPT Prompt

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.

Retool Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Learning is great. Shipping is faster with help.

Our engineers have built 600+ apps on Retool and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.