intertwingly

It’s just data

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:

  1. Event Prep (days/weeks before) - Studio uploads entries, organizers assign heats, configure scoring, generate invoices
  2. Live Event (competition day) - Judges score performances in real-time, displays update for audience
  3. 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:

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:

  1. Work offline - Queue score updates when network unavailable
  2. Stay in sync - Detect server changes (scratched heats, corrections) and refresh
  3. Rails integration - Work with existing Rails views, Turbo, Stimulus
  4. Minimal dependencies - No massive framework rewrite
  5. 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:

For showcase, I'm not building a complex dashboard with thousands of interactive widgets. I'm building a scoring interface with:

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:

Why I didn't use them for showcase:

What I actually needed:

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:

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:

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:

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):

Heat type components (4 files, ~1,500 lines):

Shared components (3 files, ~500 lines):

Data manager (heat_data_manager.js, 341 lines):

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):

Backend API tests (Rails/Minitest):

System tests (Capybara/Selenium):

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:

  1. Judge enters score → submits form
  2. Network hiccup (3 seconds)
  3. Form submission fails
  4. Score lost, judge frustrated
  5. Judge re-enters score, hopes network works

SPA with offline support:

  1. Judge enters score → POST succeeds (instant feedback)
  2. Judge navigates to next heat
  3. Network drops (router reboot, Wi-Fi glitch)
  4. Judge enters score → Added to dirty queue
  5. Judge enters 5 more scores → All queued
  6. Network returns
  7. Dirty scores batch upload automatically
  8. Judge never interrupted

The difference: Judges keep working. Event stays on schedule.

When This Pattern Fits

This architecture has a sweet spot:

Ideal for:

Not appropriate for:

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:

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:

  1. Rails owns the truth - Server is authoritative when online
  2. IndexedDB provides resilience - Cache + dirty queue for offline operation
  3. Custom Elements provide structure - No framework overhead, just native browser APIs
  4. 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.