# How to Use Asynchronous Queries in Retool

- Tool: Retool
- Difficulty: Advanced
- Time required: 20-25 min
- Compatibility: Retool Cloud and Self-hosted
- Last updated: March 2026

## 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.

## 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.

## Before you start

- 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.

```
// WITHOUT await — race condition
updateRecord.trigger(); // Fires and forgets
const refreshed = await getRecords.trigger(); // May run BEFORE update finishes!
console.log(getRecords.data); // May return stale data

// WITH await — correct order guaranteed
await updateRecord.trigger(); // Wait for update to complete
await getRecords.trigger();   // Then refresh — always stale-free
console.log(getRecords.data); // Fresh data from the completed query

// Access result directly from the trigger() return value
const result = await createRecord.trigger();
console.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.

```
// SEQUENTIAL — slow (3 queries × ~500ms each = ~1500ms)
await getCustomers.trigger();
await getOrders.trigger();
await getProducts.trigger();

// PARALLEL — fast (~500ms total if all take ~500ms)
await Promise.all([
  getCustomers.trigger(),
  getOrders.trigger(),
  getProducts.trigger()
]);

// All data is now available:
console.log('Customers:', getCustomers.data?.length);
console.log('Orders:', getOrders.data?.length);
console.log('Products:', getProducts.data?.length);

// With Promise.allSettled() — never rejects, even if one fails
const results = await Promise.allSettled([
  getCustomers.trigger(),
  getOrders.trigger(),
  getProducts.trigger()
]);

results.forEach((result, i) => {
  if (result.status === 'rejected') {
    console.error(`Query ${i} failed:`, result.reason);
  }
});
```

**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.

```
// SQL Query: 'updateOrderStatus'
// SQL: UPDATE orders SET status = {{ newStatus }}, updated_at = NOW()
//       WHERE id = {{ orderId }}

// JS Query: batchUpdateOrders
const selectedRows = table1.selectedRows;
const targetStatus = newStatusSelect.value;

for (const row of selectedRows) {
  // additionalScope injects runtime values into the SQL query's {{ }} bindings
  await updateOrderStatus.trigger({
    additionalScope: {
      orderId: row.id,         // Overrides {{ orderId }}
      newStatus: targetStatus  // Overrides {{ newStatus }}
    }
  });
}

// Refresh the table after all updates
await getOrders.trigger();

utils.showNotification({
  title: `${selectedRows.length} orders updated to ${targetStatus}`,
  notificationType: 'success'
});
```

**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.

```
// THE GOTCHA — reads old value
isLoading.setValue(true);         // Does NOT wait
console.log(isLoading.value);     // Still false!

// CORRECT — with await
await isLoading.setValue(true);   // Waits for update
console.log(isLoading.value);     // true ✓

// Practical example: loading indicator
await isLoading.setValue(true);
try {
  await fetchData.trigger();
  await processResult.trigger();
} finally {
  await isLoading.setValue(false); // Always clears loading
}

// Another gotcha: setIn() is also async
await formData.setIn(['email'], emailInput.value);
await formData.setIn(['name'], nameInput.value);
console.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.

```
// Option 1: Read query.data after await
await getCustomer.trigger({
  additionalScope: { id: table1.selectedRow.data.id }
});
const customer = getCustomer.data[0]; // Read from query.data
console.log('Customer:', customer.name);

// Option 2: Capture return value
const result = await getCustomer.trigger({
  additionalScope: { id: table1.selectedRow.data.id }
});
const customer2 = result[0]; // Same data, captured directly

// Option 3: Parallel with named results
const [customerResult, ordersResult] = await Promise.all([
  getCustomer.trigger({ additionalScope: { id: selectedId } }),
  getCustomerOrders.trigger({ additionalScope: { customerId: selectedId } })
]);

const customerName = customerResult?.[0]?.name;
const orderCount = ordersResult?.length;
return { 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 code example

File: `JS Query: complexAsyncWorkflow`

```javascript
// Complex async workflow demonstrating all patterns:
// - Sequential awaits for dependent operations
// - Promise.all() for independent parallel fetches
// - additionalScope for parameterized triggers
// - setValue() async handling
// - try/catch/finally for error recovery

const customerId = table1.selectedRow.data?.id;

if (!customerId) {
  utils.showNotification({ title: 'Select a customer first', notificationType: 'warning' });
  return;
}

// Step 1: Set loading state (async, must await)
await isLoading.setValue(true);
await loadingMessage.setValue('Loading customer data...');

try {
  // Step 2: Fetch customer + recent orders in PARALLEL
  const [customerData, ordersData] = await Promise.all([
    getCustomer.trigger({ additionalScope: { id: customerId } }),
    getCustomerOrders.trigger({ additionalScope: { customerId, limit: 10 } })
  ]);
  
  const customer = customerData?.[0];
  if (!customer) {
    throw new Error(`Customer ${customerId} not found`);
  }
  
  // Step 3: Based on customer status, fetch additional data SEQUENTIALLY
  if (customer.status === 'vip') {
    await loadingMessage.setValue('Loading VIP benefits...');
    // This MUST run after we know it is a VIP — sequential is correct here
    await getVipBenefits.trigger({ additionalScope: { customerId } });
  }
  
  // Step 4: Compute and return enriched data
  const enriched = {
    ...customer,
    orderCount: ordersData?.length || 0,
    totalSpent: ordersData?.reduce((sum, o) => sum + o.amount, 0) || 0,
    recentOrders: ordersData?.slice(0, 5) || [],
    vipBenefits: customer.status === 'vip' ? getVipBenefits.data : null
  };
  
  // Step 5: Update state with results
  await selectedCustomerData.setValue(enriched);
  
  return enriched;
  
} catch (err) {
  console.error('Customer load failed:', err);
  utils.showNotification({
    title: 'Load failed',
    description: err.message,
    notificationType: 'error'
  });
  return null;
  
} finally {
  // Always clears loading state — even on error
  await isLoading.setValue(false);
  await loadingMessage.setValue('');
}
```

## Common mistakes

- **Calling query.trigger() without await and then immediately reading query.data** — undefined Fix: Add await before query.trigger(). Without it, the script continues before the query completes and query.data holds stale data.
- **Using Promise.all() for queries that depend on each other's results** — undefined Fix: 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.
- **Reading a state variable immediately after setValue() without await** — undefined Fix: setValue() is asynchronous. Use await state1.setValue(value) before reading state1.value in the same execution path.
- **Forgetting that event handlers on components run in parallel — two handlers can trigger overlapping async operations** — undefined Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-use-asynchronous-queries-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-use-asynchronous-queries-in-retool
