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.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Beginner |
| Time required | 20-25 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
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.
Prerequisites
- 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
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.
1// ❌ PROBLEMATIC: Two separate event handlers on one button click2// Handler 1: Trigger query 'updateRecord'3// Handler 2: Trigger query 'refreshList'4// These run in PARALLEL — refreshList may finish before updateRecord56// ✅ CORRECT: Use one JS Query with sequential awaits7// Handler 1: Trigger JS Query 'saveAndRefresh'89// JS Query: saveAndRefresh10await updateRecord.trigger();11await refreshList.trigger(); // Only runs after updateRecord completesExpected result: Understanding that multiple event handlers run in parallel and the fix is a single JS Query with await chains.
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.
1// JS Query: submitAndRefresh2// Replace all parallel event handlers with this single query34// Step 1: Validate inputs before writing5if (!nameInput.value || !emailInput.value) {6 utils.showNotification({7 title: 'Validation failed',8 description: 'Name and email are required.',9 notificationType: 'error',10 });11 return; // Stop execution12}1314// Step 2: Run the write operation15await updateRecord.trigger();1617// Step 3: Only refresh AFTER the write completes18await refreshList.trigger();1920// Step 4: Close modal after both complete21await detailModal.close();2223utils.showNotification({24 title: 'Saved successfully',25 notificationType: 'success',26});Expected result: Save button triggers operations sequentially: write completes, then list refreshes, then modal closes.
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.
1// JS Query: submitAndRefresh with double-submission prevention23// Guard against concurrent submissions4if (isSubmitting.value) {5 return; // Already in progress6}78// Note: setValue is async — don't read isSubmitting immediately after9await isSubmitting.setValue(true);1011try {12 await updateRecord.trigger();13 await refreshList.trigger();14 await detailModal.close();15 utils.showNotification({ title: 'Saved', notificationType: 'success' });16} catch (err) {17 utils.showNotification({18 title: 'Save failed',19 description: err.message,20 notificationType: 'error',21 });22} finally {23 await isSubmitting.setValue(false);24}2526// Button Disabled property: {{ isSubmitting.value }}Expected result: Save button is disabled during submission. Double-clicks do not trigger duplicate writes.
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.
1// JS Query: optimisticUpdate23// Store original value for rollback4const originalData = table1.data;56// Optimistically update the local display7const optimisticData = table1.data.map(row =>8 row.id === table1.selectedRow.data.id9 ? { ...row, status: statusSelect.value }10 : row11);12await localTableData.setValue(optimisticData);1314// Send write to server15try {16 await updateStatus.trigger();17 // Success: refresh from server to confirm18 await refreshList.trigger();19} catch (err) {20 // Rollback on failure21 await localTableData.setValue(originalData);22 utils.showNotification({23 title: 'Update failed — reverted',24 description: err.message,25 notificationType: 'error',26 });27}Expected result: UI updates instantly on user action. If server write fails, UI reverts to the original state automatically.
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.
1-- Optimistic locking UPDATE query2-- Uses 'updated_at' timestamp to detect concurrent modifications3UPDATE records4SET5 status = {{ statusSelect.value }},6 notes = {{ notesInput.value }},7 updated_at = NOW(),8 updated_by = {{ retoolContext.currentUser.email }}9WHERE10 id = {{ table1.selectedRow.data.id }}11 -- Fail the update if someone else has modified the record12 AND updated_at = {{ table1.selectedRow.data.updated_at }}13RETURNING id;1415-- JS check after UPDATE:16-- if (updateRecord.data.length === 0) show conflict warningExpected result: Concurrent saves are detected. Second user sees a 'record was modified by another user' conflict message.
Complete working example
1// JS Query: safeSubmitWithConcurrencyControls2// Full implementation combining all concurrency patterns34// 1. Guard against double-submission5if (isSubmitting.value) {6 utils.showNotification({ title: 'Please wait...', notificationType: 'warning' });7 return;8}910// 2. Validate inputs11if (!titleInput.value?.trim()) {12 utils.showNotification({ title: 'Title is required', notificationType: 'error' });13 return;14}1516await isSubmitting.setValue(true);1718try {19 // 3. Run write with optimistic locking20 await updateRecord.trigger();2122 // 4. Check for concurrent modification conflict23 if (!updateRecord.data || updateRecord.data.length === 0) {24 utils.showNotification({25 title: 'Conflict detected',26 description: 'Another user modified this record. Please reload and try again.',27 notificationType: 'error',28 duration: 8,29 });30 // Reload to get latest data31 await refreshList.trigger();32 return;33 }3435 // 5. Sequential post-save operations36 await refreshList.trigger(); // Refresh master table37 await detailModal.close(); // Close form modal3839 utils.showNotification({40 title: 'Saved successfully',41 notificationType: 'success',42 });43} catch (err) {44 utils.showNotification({45 title: 'Save failed',46 description: err.message || 'An unexpected error occurred.',47 notificationType: 'error',48 });49} finally {50 // Always reset the submission flag51 await isSubmitting.setValue(false);52}Common mistakes when handling Concurrency in Retool Applications
Why it's a problem: Adding two event handlers to a button click and expecting them to run in order (first A, then B)
How to avoid: Multiple event handlers on the same trigger always run in parallel. Replace them with a single JS Query that awaits A then B sequentially.
Why it's a problem: Setting isSubmitting.setValue(true) and immediately reading isSubmitting.value expecting true
How to avoid: 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.
Why it's a problem: Not including a finally block with isSubmitting.setValue(false), leaving the button permanently disabled on error
How to avoid: 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.
Why it's a problem: Using event handlers that call other queries' success handlers to create a chain, assuming order is preserved
How to avoid: 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
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Retool form with a Save button that needs to: (1) validate inputs, (2) run an updateRecord SQL query, (3) only after updateRecord succeeds, run refreshList, (4) close a modal. The problem is I had two event handlers and they ran in parallel. Show me the complete JS Query that handles this sequentially with: an isSubmitting guard (Temporary State boolean), try/catch/finally, optimistic locking check (detecting if updateRecord.data.length === 0 means conflict), and proper error notifications.
Create a Retool JS Query 'safeSubmit' for a form save button. It should: check if isSubmitting.value is true (return early if so), set isSubmitting to true via await isSubmitting.setValue(true), await updateRecord.trigger(), check updateRecord.data.length === 0 for concurrent modification conflict, await refreshList.trigger() on success, use try/catch/finally to always set isSubmitting back to false. Show the button's Disabled property expression.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation