Offline-First Scoring with Web Components and Rails
The showcase application runs live dance competitions where judges score performances in real-time. Brief server hiccups during event prep or after-event publishing are mere inconveniences. But network outages during the event itself—when judges are actively scoring—can be catastrophic.
Traditional Rails form submissions fail immediately when offline. Judges lose work, scoring stops, events grind to a halt. The solution? Build a Single Page Application that gracefully handles offline operation while keeping Rails as the authoritative source of truth when connectivity returns.
This post documents the architectural choices and implementation patterns that enabled offline-first scoring in showcase. If you're building event-driven applications, dashboards, or any system where brief connectivity loss shouldn't mean complete failure, this pattern might fit your needs.
The Network Reality of Live Events
Dance competitions have a predictable timeline:
- Event Prep (days/weeks before) - Studio uploads entries, organizers assign heats, configure scoring, generate invoices
- Live Event (competition day) - Judges score performances in real-time, displays update for audience
- Publishing (after event) - Results compiled, PDFs generated, scores finalized
Network requirements vary dramatically:
Prep phase: Occasional outages are annoying but not critical. Studios can save invoices as drafts, wait for connectivity to return, then submit. Work can pause without blocking the event.
Live event: Network outages are mission-critical failures. With 200+ heats scheduled over 8 hours and 5-8 judges scoring simultaneously, even a 5-minute outage means:
- Judges can't enter scores
- Live displays freeze
- Scoring workflow stops
- Event falls behind schedule
Publishing: Outages delay final results but don't block the competition itself.
The key insight: Network reliability matters most when judges are actively scoring. That's when offline support provides the most value.
Why Custom Elements / Web Components?
When I started exploring SPA options for showcase, the requirements were clear:
- Work offline - Queue score updates when network unavailable
- Stay in sync - Detect server changes (scratched heats, corrections) and refresh
- Rails integration - Work with existing Rails views, Turbo, Stimulus
- Minimal dependencies - No massive framework rewrite
- Progressive enhancement - Traditional Rails forms still work
The existing application used Hotwire Turbo Frames for the traditional scoring interface—server-rendered HTML snippets that avoid full page reloads. This works great when online, but every navigation requires a server round-trip. For offline resilience, I needed an alternative that could work with cached data and queue updates locally.
I evaluated several approaches:
React/Vue/Svelte?
These frameworks excel at complex UIs but bring significant overhead:
- ❌ Need build step (Webpack/Vite configuration)
- ❌ Separate from Rails ecosystem (different mental model)
- ❌ Large bundle sizes (React alone is ~40KB minified)
- ❌ Requires rewriting existing Rails views entirely
- ❌ Harder to progressively enhance existing pages
For showcase, I'm not building a complex dashboard with thousands of interactive widgets. I'm building a scoring interface with:
- A heat list (simple navigation)
- A scoring table (drag-and-drop or radio buttons)
- Score queuing and sync when connectivity returns
Service Workers?
Service Workers are the standard approach for offline-first web apps. They intercept network requests, cache resources, and enable true offline functionality. They're powerful but bring complexity:
What they're good at:
- ✅ Caching assets (CSS, JavaScript, images)
- ✅ Background sync
- ✅ Push notifications
- ✅ Full offline shell architecture
Why I didn't use them for showcase:
- ❌ State management complexity - Service Workers run in a separate thread from your application, requiring message passing for communication
- ❌ Cache invalidation is hard - Need to version cache keys, handle updates across multiple clients
- ❌ Debugging difficulty - Service Worker lifecycle (installing, waiting, activating) adds mental overhead
- ❌ Over-engineered for the need - I don't need full offline shell or asset caching (judges always load from server)
What I actually needed:
- Queue score updates when POST fails
- Use cached heat data when version check fails
- Sync queued scores when connectivity returns
IndexedDB alone handles all of this without Service Worker complexity. The application's JavaScript manages the state machine directly—no separate worker thread needed.
Service Workers shine for applications that need to work completely offline (PWAs, news readers, offline-first apps). For showcase, judges need server connectivity eventually—I just need resilience during brief outages. IndexedDB + fetch error handling provides that resilience without the operational complexity of Service Workers.
Web Components (Custom Elements)?
Custom Elements are part of the browser's native Web Components standard. They provide:
- ✅ Zero dependencies (native browser API)
- ✅ Works with Rails views (progressive enhancement)
- ✅ Small footprint (~3KB for component framework)
- ✅ Easy client-side state management
- ✅ Works with import maps (no build step needed)
- ✅ Encapsulation without framework overhead
Here's what sold me:
1. Rails import maps support - No Webpack, no esbuild, just:
# config/importmap.rb
pin "components/heat-page", to: "components/heat-page.js"
2. Gradual adoption - Mix traditional Rails views and Custom Elements:
<!-- Traditional Rails view with Custom Element -->
<%= link_to "Traditional Scoring", judge_path(@judge) %>
<%= link_to "SPA Scoring", judge_spa_path(@judge) %>
<!-- SPA view -->
<heat-page judge-id="<%= @judge.id %>" heat-number="<%= @heat.number %>"></heat-page>
3. Simple lifecycle - Just connectedCallback() and render:
export class HeatList extends HTMLElement {
connectedCallback() {
this.judgeId = this.getAttribute('judge-id');
this.render();
}
render() {
this.innerHTML = `<div>Heat list for ${this.judgeId}</div>`;
}
}
customElements.define('heat-list', HeatList);
4. Composable - Components nest naturally:
<heat-page>
<heat-list></heat-list>
<!-- or -->
<heat-table></heat-table>
<heat-navigation></heat-navigation>
</heat-page>
The Architecture: Rails + IndexedDB + Custom Elements
The pattern is straightforward:
┌─────────────────────────────────────────┐
│ Rails Server (Source of Truth) │
│ GET /scores/:judge/heats.json │
│ POST /scores/:judge/post │
│ POST /scores/:judge/batch │
└────────────┬────────────────────────────┘
│ Online: POST immediately
│ Offline: Store in IndexedDB
↓
┌─────────────────────────────────────────┐
│ Browser (Custom Elements + IndexedDB)│
│ ┌─────────────────────────────────┐ │
│ │ <heat-page> │ │
│ │ Orchestrates navigation │ │
│ │ Manages online/offline state │ │
│ │ Holds heat data in memory │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ <heat-list> │ │ │
│ │ │ Shows all heats │ │ │
│ │ └─────────────────────────┘ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ <heat-table> │ │ │
│ │ │ Scoring interface │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ IndexedDB │ │
│ │ - Dirty scores (pending POST) │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
Key Design Decisions
1. Server is always source of truth
When online, Rails owns the data:
- Fetch heat data from
/scores/:judge/heats.json - POST scores to
/scores/:judge/post - Check for changes via
/scores/:judge/version/:heat
IndexedDB is just a cache and offline queue, never authoritative.
2. Lightweight version checks
Instead of fetching full heat data on every navigation, check a lightweight version endpoint first:
# GET /scores/:judge/version/:heat
{
"max_updated_at": "2025-11-06T15:30:00Z",
"heat_count": 142
}
If version matches cached version → use cache (fast, zero bandwidth). If version differs → fetch full data (handles scratches, corrections).
3. Dirty scores queue (last-write-wins)
Failed POSTs go into IndexedDB as "dirty scores":
{
judge_id: 55,
dirty_scores: [
{ heat: 100, slot: 1, score: 'G', timestamp: 1699300100000 },
{ heat: 101, slot: 1, score: 'S', timestamp: 1699300200000 }
]
}
When reconnected, batch upload all dirty scores:
POST /scores/:judge/batch
{
scores: [
{ heat: 100, slot: 1, score: 'G', ... },
{ heat: 101, slot: 1, score: 'S', ... }
]
}
Rails processes batch in a transaction. No complex conflict resolution—last write wins.
4. Online/offline state machine
The app exists in three states:
- Online + Connected: POST immediately, version checks work
- Online + Temporarily Offline: Queue scores, use cache, retry on reconnection
- Offline (No Network): Queue everything, use cache until network returns
JavaScript detects state changes:
window.addEventListener('online', async () => {
await heatDataManager.batchUploadDirtyScores(judgeId);
await heatDataManager.getData(judgeId); // Refresh
});
window.addEventListener('offline', () => {
console.log('[HeatPage] Offline mode - queueing score updates');
});
What Changes Were Needed?
Showcase already had traditional Rails scoring views working. To add offline-first SPA support, I needed:
1. New Rails Endpoints (3 additions)
# config/routes.rb
get '/scores/:judge/spa', to: 'scores#spa'
get '/scores/:judge/version/:heat', to: 'scores#version_check'
post '/scores/:judge/batch', to: 'scores#batch_scores'
SPA view endpoint - Serves minimal HTML shell:
<!-- app/views/scores/spa.html.erb -->
<heat-page
judge-id="<%= @judge.id %>"
heat-number="<%= params[:heat] %>"
style="<%= params[:style] || 'radio' %>">
</heat-page>
Version check endpoint - Returns lightweight metadata:
def version_check
heats = Heat.where(number: 1..).order(:number)
render json: {
max_updated_at: heats.maximum(:updated_at),
heat_count: heats.count
}
end
Batch upload endpoint - Processes queued scores:
def batch_scores
succeeded = []
failed = []
params[:scores].each do |score_params|
# Process each score in transaction
# Track success/failure
end
render json: { succeeded: succeeded, failed: failed }
end
2. JavaScript Components (9 files, ~2,800 lines)
Core orchestrator (heat-page.js, 624 lines):
- Manages navigation between heats
- Handles online/offline state
- Coordinates dirty score uploads
- Renders appropriate child components
Heat type components (4 files, ~1,500 lines):
heat-solo.js- Solo performance scoringheat-table.js- Table/grid scoring interfaceheat-cards.js- Card-based drag-and-dropheat-rank.js- Ranking finals
Shared components (3 files, ~500 lines):
heat-header.js- Heat number, category, danceheat-info-box.js- Event info, instructionsheat-navigation.js- Prev/next buttons
Data manager (heat_data_manager.js, 341 lines):
- IndexedDB operations (cache, dirty queue)
- Fetch heat data from server
- Batch upload dirty scores
- Version comparison logic
3. Import Maps Configuration
# config/importmap.rb
pin "application"
pin "components/heat-page"
pin "components/heat-list"
pin "helpers/heat_data_manager"
# ... etc
No build step. Just import:
import { HeatPage } from 'components/heat-page';
import { heatDataManager } from 'helpers/heat_data_manager';
4. Comprehensive Testing (~880 lines)
JavaScript unit tests (Vitest):
- IndexedDB operations (dirty queue, last-write-wins)
- Offline/online state transitions
- Batch upload success/failure scenarios
Backend API tests (Rails/Minitest):
- Version check endpoint accuracy
- Batch upload processing
- Empty score deletion logic
System tests (Capybara/Selenium):
- SPA loads and displays heat list
- Score submission works end-to-end
- Navigation between heats
See SPA_SYNC_STRATEGY.md Testing Strategy for complete details.
What This Looks Like in Practice
Scenario: Judge scoring heats during live competition
Traditional Rails form:
- Judge enters score → submits form
- Network hiccup (3 seconds)
- Form submission fails
- Score lost, judge frustrated
- Judge re-enters score, hopes network works
SPA with offline support:
- Judge enters score → POST succeeds (instant feedback)
- Judge navigates to next heat
- Network drops (router reboot, Wi-Fi glitch)
- Judge enters score → Added to dirty queue
- Judge enters 5 more scores → All queued
- Network returns
- Dirty scores batch upload automatically
- Judge never interrupted
The difference: Judges keep working. Event stays on schedule.
When This Pattern Fits
This architecture has a sweet spot:
Ideal for:
- ✅ Event-driven applications (conferences, competitions, voting)
- ✅ Field data collection (inspections, surveys, audits)
- ✅ Dashboards with real-time updates
- ✅ Applications where brief connectivity loss shouldn't stop work
- ✅ Progressive enhancement of existing Rails apps
- ✅ Minimal JavaScript dependencies preferred
Not appropriate for:
- ❌ Complex collaborative editing (needs operational transforms)
- ❌ Real-time chat (needs bidirectional WebSocket)
- ❌ Applications with complex client-side business logic
- ❌ Heavy UI frameworks already in use (React/Vue)
- ❌ Apps with no offline use case
The key question: Can your users keep working for minutes or even hours with stale cached data, then sync when connectivity returns?
If yes, this pattern provides resilience without massive framework overhead.
Try It Yourself
The complete implementation is open source:
- Showcase application: github.com/rubys/showcase
- SPA implementation: app/javascript/components/
- Strategy document: plans/SPA_SYNC_STRATEGY.md
Key files to examine:
app/
├── javascript/
│ ├── components/
│ │ ├── heat-page.js # Core orchestrator
│ │ ├── heat-list.js # Heat navigation
│ │ └── heat-types/
│ │ └── heat-table.js # Scoring interface
│ └── helpers/
│ └── heat_data_manager.js # IndexedDB + sync
├── controllers/
│ └── scores_controller.rb # API endpoints
└── views/
└── scores/
└── spa.html.erb # Minimal shell
Conclusion
Network outages shouldn't turn scoring systems into paperweights. Web Components provide a lightweight path to offline-first SPAs that integrate naturally with Rails.
The pattern is simple:
- Rails owns the truth - Server is authoritative when online
- IndexedDB provides resilience - Cache + dirty queue for offline operation
- Custom Elements provide structure - No framework overhead, just native browser APIs
- Progressive enhancement - Traditional Rails views still work
For showcase, this will enable judges to keep scoring during network glitches, events to stay on schedule, and operations to remain reliable.
Building resilient event applications isn't magic—it's just embracing the right architecture for the problem.
For insights into the development methodology that made this possible, see Disciplined use of Claude.
For complete implementation details including version checking, batch uploads, and testing strategy, see SPA_SYNC_STRATEGY.md in the showcase repository.