Migrating from Rails UJS to Turbo: The Hidden Pitfalls That Broke Our Production App
Migrating from Rails UJS to Turbo: The Hidden Pitfalls That Broke Our Production App
A cautionary tale about event timing, async promises, and why your tests might be lying to you
The Silent Failure
Our Rails application had been running smoothly for years with Turbolinks and Rails UJS handling AJAX form submissions. When we upgraded to Rails 7 and Turbo (Hotwired), the deployment went without a hitch. Our test suite was green. Everything appeared to work.
But production users immediately hit a wall: submit buttons stayed disabled, results didn’t display, and interactive exercises couldn’t be reset. The tests passed, but the app was broken.
This is the story of how we debugged the issue, what we learned about Turbo’s event lifecycle, and how to properly migrate from Rails UJS to Turbo without falling into the same traps.
The Architecture: A Learning Platform
Our application is an English learning platform where students:
- Answer introduction questions (yes/no)
- Complete article comprehension (5 multiple choice)
- Submit opinions and see poll results
- Practice with interactive exercises (Highlighter, Word Match, Blockbuster)
- Reset and retry exercises for mastery
The key UX pattern: submit buttons start disabled and enable when all questions are answered. This prevents partial submissions and guides the user through the workflow.
The implementation relied heavily on AJAX form submissions with JavaScript controllers listening to success events to:
- Enable submit buttons (when all answers collected)
- Display results (correct/wrong feedback)
- Reset activities (replace content with fresh state)
The Symptom: Everything Breaks Silently
After upgrading to Turbo, users encountered:
Problem 1: Submit Buttons Never Enable
1
User answers all 5 questions → Submit button stays disabled → Cannot proceed
Problem 2: No Results Display
1
User clicks Submit → No feedback appears → Page looks frozen
Problem 3: Reset Buttons Don’t Work
1
User completes exercise → Clicks Reset → Nothing happens
The confusing part? Our integration tests passed. The test suite completed full lesson flows successfully. We only discovered the bugs when QA manually tested the reset functionality—something our automated tests never exercised.
The Root Cause: Event Format Changes
Our JavaScript controllers were written for Rails UJS, which emits custom events like ajax:success with a specific data format:
1
2
3
4
5
6
// Rails UJS event format
document.addEventListener('ajax:success', (event) => {
const [data, status, xhr] = event.detail
const responseText = data // HTML string
const statusCode = xhr.status // 200, 201, 202, etc.
})
When we upgraded to Turbo, these events stopped firing. Turbo intercepts form submissions and emits its own events with a completely different format:
1
2
3
4
5
6
// Turbo event format
document.addEventListener('turbo:submit-end', (event) => {
const response = event.detail.fetchResponse.response
const responseText = event.detail.fetchResponse.responseText // ⚠️ Promise!
const statusCode = response.status
})
Our controllers were listening to events that never fired, so they never updated the UI.
The False Fix: Dual Format Support
Our first attempt tried to support both formats:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ This seems reasonable but has subtle bugs
update(event) {
let html, code
if (event.detail && event.detail.length > 1) {
// Rails UJS format
html = event.detail[0]
code = event.detail[2]?.status
} else if (event.detail?.fetchResponse) {
// Turbo format
html = event.detail.fetchResponse.responseText
code = event.detail.fetchResponse.response.status
}
this.element.innerHTML = html
if (code === 202) {
this.enableSubmitButton()
}
}
This code has three critical bugs:
Bug 1: responseText is a Promise
The Turbo FetchResponse.responseText is a Promise, not a string. Using it directly results in:
1
this.element.innerHTML = html // Sets innerHTML to "[object Promise]"
The UI updates with literal text [object Promise] instead of the HTML we wanted.
Bug 2: Wrong Event Timing
We initially used turbo:submit-end, which fires after Turbo has already processed the response. At that point:
- Turbo has already tried to navigate or update the page
- Calling
event.preventDefault()does nothing - We get console errors: “Form responses must redirect to another location”
Bug 3: Incomplete Migration
With dual-format code, it’s easy to miss updating all the forms in your views. One form still using ajax:success will silently fail because the event never fires.
The Correct Solution: Complete Migration to Turbo
Step 1: Use the Right Event
Use turbo:before-fetch-response instead of turbo:submit-end:
1
2
3
4
5
6
7
// ✅ Correct: Intercept before Turbo processes the response
async replace(event) {
event.preventDefault() // Stops Turbo navigation
const html = await event.detail.fetchResponse.responseText
this.element.outerHTML = html
}
The Turbo event lifecycle:
1
2
3
4
5
6
7
turbo:submit-start → Form about to submit (can cancel)
↓
turbo:before-fetch-request → HTTP request about to send (can modify)
↓
turbo:before-fetch-response → Response received, NOT processed ⭐ USE THIS
↓
turbo:submit-end → Response processed (too late for preventDefault)
Why turbo:before-fetch-response?
- Fires before Turbo processes the response
event.preventDefault()successfully stops navigation- Gives you the response to handle manually
- Prevents “Form responses must redirect” errors
Step 2: Await All Promises
1
2
3
4
5
// ✅ Correct: Await the Promise
const html = await event.detail.fetchResponse.responseText
// ❌ Wrong: Using Promise directly
const html = event.detail.fetchResponse.responseText // [object Promise]
FetchResponse properties:
| Property | Type | Usage |
|---|---|---|
response.status | number | Direct access ✅ |
response.ok | boolean | Direct access ✅ |
responseText | Promise<string> | Must await ⚠️ |
responseHTML | Promise<string> | Must await ⚠️ |
Step 3: Update All Views
Change every form from Rails UJS events to Turbo events:
1
2
3
4
<%# Before: Rails UJS %>
<%= form_with url: answer_path,
- data: { action: 'ajax:success->result#update' } %>
+ data: { action: 'turbo:before-fetch-response->result#update' } %>
This is non-negotiable. You must update every single form that uses JavaScript event handlers. Miss one, and you’ll have silent failures.
Real-World Example: Submit Button Controller
Here’s a complete before/after for our submit button controller:
Before (Rails UJS)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/frontend/controllers/result_controller.js
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['form']
update(event) {
const [data, status, xhr] = event.detail
if (status === 'Accepted' || xhr.status === 202) {
this.formTarget.disabled = false
this.formTarget.querySelectorAll('[type="submit"]').forEach(submit => {
submit.disabled = false
})
}
}
}
1
2
3
/ app/views/user_lessons/_1_introduction.html.slim
= form_with url: answer_user_lesson_path(@user_lesson),
data: { action: 'ajax:success->result#update' }
After (Turbo)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/frontend/controllers/result_controller.js
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['form']
update(event) {
event.preventDefault() // Stop Turbo from processing
const code = event.detail.fetchResponse.response.status
if (code === 202) { // All questions answered
this.formTarget.disabled = false
this.formTarget.querySelectorAll('[type="submit"]').forEach(submit => {
submit.disabled = false
})
console.log('✓ All questions answered - submit button enabled')
} else if (code === 201) {
console.log('✓ Answer saved - waiting for more answers')
}
}
}
1
2
3
/ app/views/user_lessons/_1_introduction.html.slim
= form_with url: answer_user_lesson_path(@user_lesson),
data: { action: 'turbo:before-fetch-response->result#update' }
Key changes:
- Event:
ajax:success→turbo:before-fetch-response - Added
event.preventDefault() - Changed from
event.detail[2].statustoevent.detail.fetchResponse.response.status - Removed dual-format handling
- Added console logging for debugging
Real-World Example: Content Replacement Controller
Before (Rails UJS)
1
2
3
4
5
// app/frontend/controllers/section_controller.js
replace(event) {
const html = event.detail[0] // responseText string
this.element.outerHTML = html
}
After (Turbo)
1
2
3
4
5
6
7
// app/frontend/controllers/section_controller.js
async replace(event) {
event.preventDefault()
const html = await event.detail.fetchResponse.responseText
this.element.outerHTML = html
}
Key changes:
- Made method
asyncto useawait - Added
event.preventDefault() awaittheresponseTextPromise- Simplified (no dual-format handling)
The Testing Trap
Our integration tests passed because they tested the happy path:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# test/system/take_lesson_test.rb (INSUFFICIENT)
test 'complete lesson' do
visit lesson_path(@lesson)
# Introduction
choose 'True'
click_on 'Submit'
# Article
5.times { |i| choose "Answer #{i}" }
click_on 'Submit'
# ... continue through lesson
assert_text 'Congratulations!'
end
What this test missed:
- ✅ Forms submit successfully
- ✅ Content updates after submission
- ❌ Submit button state transitions (starts disabled, enables when ready)
- ❌ Reset functionality (never clicks Reset buttons)
- ❌ Results display (doesn’t verify correct/wrong indicators)
The test verified the outcome (lesson completion) but not the user experience (button states, feedback, reset functionality).
The Fix: Comprehensive E2E Tests
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# test/system/take_lesson_test.rb (COMPREHENSIVE)
test 'take lesson with full UX verification' do
visit lesson_path(@lesson)
within('div[data-target="lesson.introPart"]') do
# Verify submit button starts disabled
submit_button = find('button[type="submit"]')
assert submit_button.disabled?, 'Submit button should start disabled'
# Answer the question
choose 'False'
# Wait for AJAX and verify button enables
sleep 1
assert !submit_button.disabled?, 'Submit button should enable after answering'
click_on 'Submit'
end
# Verify results displayed
within('div[data-target="lesson.introPart"]') do
assert_text 'Well done!' # Feedback appears
end
# ... test article with progressive button enabling
# Test Reset functionality
within('div[data-controller="highlighter"]') do
# Complete the exercise
within('#hq-1') do
find('span[data-flag="c"]', text: 'even as').click
click_on 'Check Answer'
assert_text 'Well done!'
end
assert_text 'Click Reset to take the activity again'
# Test Reset
click_on 'Reset'
# Verify questions reset
within('#hq-1') do
assert_text 'Which linking phrase in paragraph one shows contrast?'
assert_no_text 'Well done!' # Feedback cleared
end
# Verify can answer again
within('#hq-1') do
find('span[data-flag="c"]', text: 'even as').click
click_on 'Check Answer'
assert_text 'Well done!' # Works after reset
end
end
end
What this test verifies:
- ✅ Submit button disabled state
- ✅ Submit button enables when questions answered
- ✅ Results display after submission
- ✅ Reset button clears state
- ✅ Exercise works after reset
Test results:
1
2 tests, 112 assertions, 0 failures
These tests now catch the exact bugs that slipped into production.
Migration Checklist
When upgrading from Rails UJS to Turbo, use this checklist:
Phase 1: Audit (Before Changing Anything)
- Grep for all
ajax:successlisteners in JavaScript - Grep for all
ajax:errorlisteners in JavaScript - Grep for all
ajax:completelisteners in JavaScript - Find all forms with
data: { action: 'ajax:*' }in views - Document all
Rails.fire()calls in JavaScript - List all controllers that handle form responses
Phase 2: Update JavaScript Controllers
For each controller that handles form events:
- Change
ajax:success→turbo:before-fetch-response - Change
ajax:error→turbo:submit-error - Add
event.preventDefault()at start of handler - Change
event.detail[0]→await event.detail.fetchResponse.responseText - Change
event.detail[1]→event.detail.fetchResponse.response.statusText - Change
event.detail[2].status→event.detail.fetchResponse.response.status - Make methods
asyncif usingawait - Remove all dual-format handling code
- Add console logging for debugging
Phase 3: Update Views
For each form view:
- Change
data: { action: 'ajax:success->...' }toturbo:before-fetch-response->... - Change
data: { action: 'ajax:error->...' }toturbo:submit-error->... - Remove
data: { turbo: false }if added as temporary fix - Verify form uses
form_with(notform_tag)
Phase 4: Update Form Submission Code
- Replace
Rails.fire()withform.requestSubmit() - Replace
$.ajax()with Fetch API or Turbo - Remove
remote: truefrom forms (Turbo handles this)
Phase 5: Testing
- Write tests for submit button state transitions
- Write tests for reset functionality
- Write tests for results/feedback display
- Test error handling paths
- Manual QA of all interactive forms
- Check browser console for Turbo errors
Common Pitfalls and How to Avoid Them
Pitfall 1: Using turbo:submit-end Instead of turbo:before-fetch-response
Wrong:
1
2
3
4
5
document.addEventListener('turbo:submit-end', (event) => {
event.preventDefault() // Too late! Turbo already processed the response
const html = await event.detail.fetchResponse.responseText
this.element.innerHTML = html
})
Error:
1
Form responses must redirect to another location
Right:
1
2
3
4
5
document.addEventListener('turbo:before-fetch-response', (event) => {
event.preventDefault() // Perfect timing! Stops Turbo from processing
const html = await event.detail.fetchResponse.responseText
this.element.innerHTML = html
})
Pitfall 2: Forgetting to Await Promises
Wrong:
1
2
const html = event.detail.fetchResponse.responseText
this.element.innerHTML = html // Sets innerHTML to "[object Promise]"
Right:
1
2
const html = await event.detail.fetchResponse.responseText
this.element.innerHTML = html // Sets innerHTML to actual HTML string
Pitfall 3: Incomplete View Updates
You updated the JavaScript but forgot one form:
1
2
3
4
5
6
7
/ ❌ This form still uses ajax:success - silently fails
= form_with url: answer_path,
data: { action: 'ajax:success->result#update' }
/ ✅ This form works
= form_with url: submit_path,
data: { action: 'turbo:before-fetch-response->section#replace' }
Solution: Grep your views after migration:
1
2
grep -r "ajax:success\|ajax:error\|ajax:complete" app/views/
# Should return 0 results
Pitfall 4: Tests That Don’t Match User Flows
Your test:
1
2
click_on 'Submit'
assert_text 'Success'
What it misses:
- Button state changes
- Reset functionality
- Error feedback
- Progressive enabling
Better test:
1
2
3
4
5
6
7
8
9
10
11
submit_button = find('button[type="submit"]')
assert submit_button.disabled?
choose 'Answer'
assert !submit_button.disabled?
click_on 'Submit'
assert_text 'Correct!'
click_on 'Reset'
assert submit_button.disabled? # Reset clears state
Alternative Approaches
Option 1: Turbo Streams (Recommended for New Projects)
Instead of manual content replacement with preventDefault(), use Turbo Streams:
Controller:
1
2
3
4
5
6
7
def create
@result = process_answer(params[:answer])
respond_to do |format|
format.turbo_stream # Returns turbo-stream response
end
end
View (create.turbo_stream.erb):
1
2
3
4
5
6
7
<%= turbo_stream.replace "section-intro" do %>
<%= render partial: "intro_results", locals: { result: @result } %>
<% end %>
<%= turbo_stream.update "submit-button" do %>
<button type="submit" <%= "disabled" unless @all_answered %>>Submit</button>
<% end %>
Benefits:
- No
preventDefault()needed - No manual HTML replacement
- No Promise handling
- Server-driven UI updates
- Real-time capable (via ActionCable)
Tradeoffs:
- Larger refactor from existing code
- Requires dedicated turbo_stream views
- More server round-trips
Option 2: Keep Manual Handling (Our Choice)
We chose to keep manual content replacement because:
- Smaller migration surface area
- Existing server responses work as-is
- Fine-grained control over UI updates
- No need for real-time features yet
When to use each:
- Turbo Streams: New projects, real-time features, server-driven UI
- Manual handling: Migrating existing UJS code, client-side control
Debugging Tips
1. Add Console Logging
1
2
3
4
5
6
7
8
9
10
update(event) {
console.group('result#update')
console.log('Event:', event)
console.log('Detail:', event.detail)
console.log('Status:', event.detail.fetchResponse.response.status)
console.groupEnd()
event.preventDefault()
// ... rest of code
}
2. Watch Turbo Events
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Add to application.js for debugging
document.addEventListener('turbo:before-fetch-response', (event) => {
console.log('turbo:before-fetch-response', {
url: event.detail.fetchResponse.response.url,
status: event.detail.fetchResponse.response.status,
ok: event.detail.fetchResponse.response.ok
})
})
document.addEventListener('turbo:submit-end', (event) => {
console.log('turbo:submit-end', {
success: event.detail.success,
formSubmission: event.detail.formSubmission
})
})
3. Check Response Headers
1
2
3
4
5
6
7
8
9
10
11
12
async replace(event) {
event.preventDefault()
const response = event.detail.fetchResponse.response
console.log('Content-Type:', response.headers.get('Content-Type'))
console.log('Status:', response.status)
const html = await event.detail.fetchResponse.responseText
console.log('HTML length:', html.length)
this.element.outerHTML = html
}
4. Verify Event Listeners
Open browser console and check registered listeners:
1
2
3
// In browser console
getEventListeners(document)
// Should show turbo:before-fetch-response listeners
Performance Considerations
Before (Rails UJS)
Each form submission:
- Browser sends AJAX request
- Server returns HTML partial
ajax:successfires- JavaScript updates DOM
Network: 1 request
JavaScript events: 1 event (ajax:success)
After (Turbo)
Each form submission:
- Browser sends Fetch request
- Server returns HTML partial
turbo:submit-startfiresturbo:before-fetch-requestfiresturbo:before-fetch-responsefires (we handle here)turbo:submit-endfires
Network: 1 request (same)
JavaScript events: 4 events (more overhead)
Impact: Negligible. The extra events fire in microseconds. Turbo’s caching and predictive prefetching often make the app faster overall.
Key Takeaways
1. Event Timing is Critical
Use turbo:before-fetch-response, not turbo:submit-end. The former fires before Turbo processes the response, allowing preventDefault() to work.
2. Promises are Everywhere
FetchResponse.responseText and .responseHTML are Promises. Always await them.
3. Migration Must Be Complete
You cannot have some forms using Rails UJS and others using Turbo. It’s all or nothing. Dual-format code hides bugs.
4. Tests Must Match Reality
Integration tests should verify the user experience, not just the outcome. Test button states, reset functionality, and error feedback—not just “does it submit?”
5. Console Logging is Your Friend
Add generous logging during migration. Remove it later if needed, but during migration it’s invaluable for debugging silent failures.
Conclusion
Migrating from Rails UJS to Turbo is not a drop-in replacement. The event formats are different, the timing is different, and the expectations are different. But with careful attention to:
- Event timing (
turbo:before-fetch-response) - Promise handling (
await responseText) - Complete migration (update all forms)
- Comprehensive testing (verify UX, not just outcomes)
…you can successfully migrate without breaking production.
Our migration touched 18 controller files, 8 view files, and required new comprehensive tests. It took time, but the result is a faster, more maintainable application that’s ready for Turbo’s advanced features like Streams and real-time updates.
The most important lesson: Your tests should fail when your users fail. If users can’t reset exercises but your tests pass, your tests are testing the wrong things.
Resources
- Turbo Handbook
- Turbo Events Reference
- FetchResponse API
- Stimulus Handbook
- Our GitHub PR with full migration
About the Author: Built at ReallyEnglish, a language learning platform serving thousands of students globally. This migration was completed in June 2026 with comprehensive testing to ensure zero downtime and no user-facing bugs.
License: This article is licensed under CC BY-SA 4.0. Code examples are MIT licensed.