# How to Handle Concurrency in Retool Applications

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

## TL;DR

Retool event handlers added to the same trigger (e.g., multiple handlers on one button click) run in parallel — there is no guaranteed execution order. To enforce sequence, use a single JS Query with await query.trigger() calls in order. Use a Temporary State 'isSubmitting' flag to prevent double-submission while an async operation is in progress.

## Race Conditions and How to Prevent Them in Retool

Retool's event handler system executes multiple handlers attached to the same event in parallel — not sequentially. This is by design (it's faster), but it creates race conditions when you need operations in a specific order.

The most common concurrency problem: a user clicks Save, which triggers both an 'updateRecord' query and a 'refreshList' query. If refreshList runs before updateRecord completes, the list shows the old data.

Retool provides no built-in event handler ordering — you must resolve this yourself using JS Queries with async/await. This tutorial shows the patterns: sequential execution via await chains, double-submission prevention with a locking flag, and optimistic UI updates.

## Before you start

- A Retool app with at least one form submission workflow
- Understanding of JavaScript async/await
- Familiarity with Retool Temporary State variables and setValue()

## Step-by-step guide

### 1. Understand the parallel execution problem

In the Retool Inspector, when you add multiple event handlers to a single event (e.g., button onClick with handlers: trigger 'updateRecord' AND trigger 'refreshList'), both handlers fire simultaneously. The 'updateRecord' and 'refreshList' queries start at the same time. If 'refreshList' completes before 'updateRecord', the refreshed list doesn't include the update. This is a silent bug — Retool doesn't warn you that execution order is not guaranteed.

```
// ❌ PROBLEMATIC: Two separate event handlers on one button click
// Handler 1: Trigger query 'updateRecord'
// Handler 2: Trigger query 'refreshList'
// These run in PARALLEL — refreshList may finish before updateRecord

// ✅ CORRECT: Use one JS Query with sequential awaits
// Handler 1: Trigger JS Query 'saveAndRefresh'

// JS Query: saveAndRefresh
await updateRecord.trigger();
await refreshList.trigger(); // Only runs after updateRecord completes
```

**Expected result:** Understanding that multiple event handlers run in parallel and the fix is a single JS Query with await chains.

### 2. Replace parallel handlers with a sequential JS Query

Remove all multiple event handlers from the button's onClick. Instead, add a single handler: 'Trigger query' → your new JS Query named 'submitAndRefresh'. In the JS Query, use await for each operation in the required sequence. The JS Query's async/await guarantees that each line waits for the previous to complete before proceeding. This is the fundamental pattern for sequential operations in Retool.

```
// JS Query: submitAndRefresh
// Replace all parallel event handlers with this single query

// Step 1: Validate inputs before writing
if (!nameInput.value || !emailInput.value) {
  utils.showNotification({
    title: 'Validation failed',
    description: 'Name and email are required.',
    notificationType: 'error',
  });
  return; // Stop execution
}

// Step 2: Run the write operation
await updateRecord.trigger();

// Step 3: Only refresh AFTER the write completes
await refreshList.trigger();

// Step 4: Close modal after both complete
await detailModal.close();

utils.showNotification({
  title: 'Saved successfully',
  notificationType: 'success',
});
```

**Expected result:** Save button triggers operations sequentially: write completes, then list refreshes, then modal closes.

### 3. Prevent double-submission with an isSubmitting flag

Users sometimes click Save multiple times quickly, triggering duplicate write operations. Create a Temporary State variable named 'isSubmitting' with a Boolean type and default value false. At the start of the JS Query, check if isSubmitting.value is true and return early if so. Set isSubmitting to true before the write and back to false after. Bind the Save button's Disabled property to {{ isSubmitting.value }} to visually disable it during the operation.

```
// JS Query: submitAndRefresh with double-submission prevention

// Guard against concurrent submissions
if (isSubmitting.value) {
  return; // Already in progress
}

// Note: setValue is async — don't read isSubmitting immediately after
await isSubmitting.setValue(true);

try {
  await updateRecord.trigger();
  await refreshList.trigger();
  await detailModal.close();
  utils.showNotification({ title: 'Saved', notificationType: 'success' });
} catch (err) {
  utils.showNotification({
    title: 'Save failed',
    description: err.message,
    notificationType: 'error',
  });
} finally {
  await isSubmitting.setValue(false);
}

// Button Disabled property: {{ isSubmitting.value }}
```

**Expected result:** Save button is disabled during submission. Double-clicks do not trigger duplicate writes.

### 4. Implement optimistic updates for better UX

Optimistic updates improve perceived performance by updating the UI immediately before the server confirms the write. Store the current record in a Temporary State variable, update it locally first, then send the write to the server. If the write fails, revert to the original value. This pattern makes the UI feel instant even on slow connections.

```
// JS Query: optimisticUpdate

// Store original value for rollback
const originalData = table1.data;

// Optimistically update the local display
const optimisticData = table1.data.map(row =>
  row.id === table1.selectedRow.data.id
    ? { ...row, status: statusSelect.value }
    : row
);
await localTableData.setValue(optimisticData);

// Send write to server
try {
  await updateStatus.trigger();
  // Success: refresh from server to confirm
  await refreshList.trigger();
} catch (err) {
  // Rollback on failure
  await localTableData.setValue(originalData);
  utils.showNotification({
    title: 'Update failed — reverted',
    description: err.message,
    notificationType: 'error',
  });
}
```

**Expected result:** UI updates instantly on user action. If server write fails, UI reverts to the original state automatically.

### 5. Handle multi-user write conflicts with optimistic locking

When two users edit the same record simultaneously, the second save can overwrite the first. Implement optimistic locking by including a 'version' or 'updated_at' timestamp in your UPDATE query. The WHERE clause checks that the record hasn't been modified since the user loaded it. If the WHERE clause matches no rows (row count = 0), another user already modified the record — show a conflict notification.

```
-- Optimistic locking UPDATE query
-- Uses 'updated_at' timestamp to detect concurrent modifications
UPDATE records
SET
  status = {{ statusSelect.value }},
  notes = {{ notesInput.value }},
  updated_at = NOW(),
  updated_by = {{ retoolContext.currentUser.email }}
WHERE
  id = {{ table1.selectedRow.data.id }}
  -- Fail the update if someone else has modified the record
  AND updated_at = {{ table1.selectedRow.data.updated_at }}
RETURNING id;

-- JS check after UPDATE:
-- if (updateRecord.data.length === 0) show conflict warning
```

**Expected result:** Concurrent saves are detected. Second user sees a 'record was modified by another user' conflict message.

## Complete code example

File: `JS Query: safeSubmitWithConcurrencyControls`

```javascript
// JS Query: safeSubmitWithConcurrencyControls
// Full implementation combining all concurrency patterns

// 1. Guard against double-submission
if (isSubmitting.value) {
  utils.showNotification({ title: 'Please wait...', notificationType: 'warning' });
  return;
}

// 2. Validate inputs
if (!titleInput.value?.trim()) {
  utils.showNotification({ title: 'Title is required', notificationType: 'error' });
  return;
}

await isSubmitting.setValue(true);

try {
  // 3. Run write with optimistic locking
  await updateRecord.trigger();

  // 4. Check for concurrent modification conflict
  if (!updateRecord.data || updateRecord.data.length === 0) {
    utils.showNotification({
      title: 'Conflict detected',
      description: 'Another user modified this record. Please reload and try again.',
      notificationType: 'error',
      duration: 8,
    });
    // Reload to get latest data
    await refreshList.trigger();
    return;
  }

  // 5. Sequential post-save operations
  await refreshList.trigger();    // Refresh master table
  await detailModal.close();      // Close form modal

  utils.showNotification({
    title: 'Saved successfully',
    notificationType: 'success',
  });
} catch (err) {
  utils.showNotification({
    title: 'Save failed',
    description: err.message || 'An unexpected error occurred.',
    notificationType: 'error',
  });
} finally {
  // Always reset the submission flag
  await isSubmitting.setValue(false);
}
```

## Common mistakes

- **Adding two event handlers to a button click and expecting them to run in order (first A, then B)** — undefined Fix: Multiple event handlers on the same trigger always run in parallel. Replace them with a single JS Query that awaits A then B sequentially.
- **Setting isSubmitting.setValue(true) and immediately reading isSubmitting.value expecting true** — undefined Fix: setValue() is async. Reading isSubmitting.value in the same synchronous call after setValue() will return the old value (false). Use the guard check at the start, set the flag, then proceed — do not read the flag again after setting it.
- **Not including a finally block with isSubmitting.setValue(false), leaving the button permanently disabled on error** — undefined Fix: Always wrap your submission logic in try/catch/finally. The finally block runs whether the try succeeds or the catch handles an error, guaranteeing the flag is always reset.
- **Using event handlers that call other queries' success handlers to create a chain, assuming order is preserved** — undefined Fix: Success handler chains in Retool can create hard-to-debug execution graphs. Use explicit await chains in a JS Query instead of relying on success handler chaining.

## Best practices

- Never rely on multiple parallel event handlers for operations that must happen in a specific order
- Always use a single JS Query with await chains when sequence matters
- Implement an isSubmitting guard on all write operations to prevent double-submission
- Use try/catch/finally blocks to ensure isSubmitting is always reset, even on failure
- Add optimistic locking (version timestamp in WHERE clause) for collaborative apps where multiple users edit shared data
- Display a loading/disabled state on submit buttons while isSubmitting.value is true

## Frequently asked questions

### Can I make two Retool queries run in parallel intentionally using Promise.all?

Yes — use Promise.all([query1.trigger(), query2.trigger()]) in a JS Query to run multiple queries simultaneously and wait for all to complete. This is faster than sequential await chains when the queries are independent. Use await chains only when sequence matters.

### What happens if a Retool query fails inside an await chain?

An unhandled error in a triggered query will throw a JavaScript exception that stops the JS Query's execution at that line. Subsequent await calls in the chain do not run. Wrap the chain in a try/catch block to handle failures and continue or roll back as needed.

### How do I handle concurrency in Retool when multiple users are editing the same table simultaneously?

Use optimistic locking in your UPDATE queries: add an updated_at or version column to your WHERE clause, checking that the record hasn't changed since the user loaded it. If the UPDATE affects 0 rows, another user modified the record concurrently. Show a conflict notification and prompt the user to reload.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-handle-concurrency-in-retool-applications
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-handle-concurrency-in-retool-applications
