Introduction: A Service Worker Alone Didn't Make Offline Studying Possible
Last time (Phase 1+2), we added a Service Worker so that going offline shows a fallback page instead of a blank error screen. But when you actually use the app offline, you're stuck on the very first question. That's because every tap on "Next question" goes to the server to fetch the question.
So Phase 3 is about keeping the question data itself on the client (in IndexedDB), and Phase 4 is about storing answers made offline on the device and syncing them when the device comes back online.
This article covers Phase 3+4. It's written for anyone wondering, "So what can you actually do offline?" and for Rails engineers who want to add offline capability without breaking an existing server-driven UI. It's not an IndexedDB tutorial; the focus is on design decisions.
1. Design Approach: Reuse What Exists, Add Only an Offline-Only Route
1-1. Why We Didn't Reuse the Existing Study Feature As-Is
The app's core feature has always been the "study session" (study_session). It's built to compute "the next question to show" on the server every time, with logic on the Rails side that optimizes question selection based on the user's accuracy and weak areas.
# rough sketch of the existing StudySessionsController#show (generalized)
def show
@session = current_user.study_sessions.find(params[:id])
@question = Question.weak_for_user(current_user) # computed server-side on every request
# ...
endSo every tap on "Next question" assumes a round trip to the server. Trying to run that as-is offline would mean porting the question-selection logic to the client too, which in practice is a major overhaul that rebuilds the entire frontend. The blast radius is large, and so is the risk of breaking the existing UX. Honestly, this was a part we wanted to avoid touching.
So we changed tack: leave the existing flow completely alone and add a new, separate offline-only route. Offline practice is deliberately limited to "light review with randomly served questions," while serious studying that needs optimized question selection stays with the existing server-driven sessions. In short, the decision was to let the two routes divide the roles between them. More on this in §3-1.
1-2. The Big Picture: What's New and What's Reused
Before diving into the implementation, here's an overview of what Phase 3+4 builds from scratch and what it reuses as-is. Because the policy is "don't break what exists," you can see that the server side is almost entirely reused, and new additions are limited to offline-only pieces.
| Layer | What it does | New / Existing |
|---|---|---|
| Routing | Have questions#index also respond with JSON | Reused (no new route) |
| Routing | offline_practice / offline_attempts (two member routes) | New |
| Server | A JSON method in QuestionsController | Added to existing |
| Server | OfflinePracticeController (show / bulk_create) | New |
| Client: storage | db.js / question_store.js (IndexedDB) | New |
| Client: sync | offline_sync_controller.js (automatic background sync) | New |
| Client: practice UI | offline_practice_controller.js / show.html.erb | New |
We split this across two phases. Phase 3 is the data foundation (an endpoint that serves questions as JSON → storing them in IndexedDB → automatic background sync), and Phase 4 is the page itself (a new practice page at /exams/sap/offline_practice + an answer queue + bulk sync). The "separate offline-only route" mentioned in §1 is this offline_practice page.
2. Phase 3: Keeping Question Data in IndexedDB
In Phase 3, we build only the data foundation first. Wiring it into the UI is left to Phase 4; here we get as far as "receive question data from the server as JSON → store it in IndexedDB → read it back even offline."
2-1. Server Side: Adding a JSON Method to the Questions Controller
First, we need an endpoint that serves question data as JSON. Rather than a separate namespace like /api/v1/..., we simply added JSON to the existing QuestionsController#index via respond_to. The URL is /exams/sap/questions.json.
# app/controllers/questions_controller.rb
def index
@questions = @exam.questions.order(:position)
respond_to do |format|
format.html
format.json { render json: questions_json_payload(@questions) }
end
end
private
def questions_json_payload(questions)
locale = I18n.locale.to_s
{
exam: { id: @exam.id, slug: @exam.slug, name: @exam.name },
locale: locale,
generated_at: Time.current.iso8601,
questions: questions.includes(:tags).map { |q|
{
id: q.id,
position: q.position,
domain: q.domain,
body: q.localized_body(locale),
choices: q.localized_choices(locale),
correct_answers: q.correct_answers,
source_explanation: q.localized_explanation(locale),
tags: q.tags.map(&:name)
}
}
}
endWe kept three things in mind here.
- Avoid N+1 with
includes(:tags): looping over every question and loading tags one by one causes N+1 queries, so load them all at once - Include
correct_answers: needed to grade locally offline. The answers are "information shown after you answer," so they aren't confidential (see the FAQ) - Send a single locale: the app supports three languages, but sending all questions in all three at once makes the payload heavy. Users rarely switch locales, so we send only the current locale and re-sync when it changes, recording the
localein themetastore
2-2. Client Side: Hand-Rolling the Code to Store Questions in IndexedDB
The usual choice for an IndexedDB wrapper is idb or Dexie, but this time we didn't add one. We only deal with two stores, questions and meta, and even hand-rolled it's under 100 lines. More importantly, adding npm dependencies in an importmap-rails setup affects the build configuration, so at this scale we judged a hand-rolled wrapper to be easier to follow.
Add pin_all_from "app/javascript/offline", under: "offline" to the importmap so the module can be imported as offline/question_store. The schema has two stores: questions (keyPath: "id") and meta (keyPath: "key").
// app/javascript/offline/question_store.js (as of Phase 3)
const DB_NAME = "app"
const DB_VERSION = 1
const QUESTIONS_STORE = "questions"
const META_STORE = "meta"
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = event.target.result
const store = db.createObjectStore(QUESTIONS_STORE, { keyPath: "id" })
store.createIndex("exam_slug", "exam_slug", { unique: false })
store.createIndex("position", "position", { unique: false })
db.createObjectStore(META_STORE, { keyPath: "key" })
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
export async function saveQuestions(examSlug, questions, locale) {
// put all records in one transaction; record sync time and count in meta
}2-3. A Stimulus Sync Controller (Silent Sync)
When should fetching and storing the data run? We went with automatically fetching when the app is opened online and at least an hour has passed since the last sync.
We made this a Stimulus controller called offline_sync_controller.js and attached it to the entire logged-in layout. All it takes is placing <div data-controller="offline-sync" data-offline-sync-exam-slug-value="sap"> right after the nav.
- Check
navigator.onLineinconnect() - If online, check the last sync time, and if an hour or more has passed,
fetch("/exams/sap/questions.json")→saveQuestions() - Subscribe to the
online/offlineevents onwindowand toggle the offline banner accordingly
// app/javascript/controllers/offline_sync_controller.js
import { Controller } from "@hotwired/stimulus"
import { saveQuestions, countQuestions, getSyncMeta } from "offline/question_store"
const SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1 hour
export default class extends Controller {
static values = { examSlug: String }
connect() {
window.addEventListener("online", () => this.maybeSync())
window.addEventListener("offline", () => this.handleOffline())
navigator.onLine ? this.maybeSync() : this.handleOffline()
}
// fetch if an hour or more has passed since the last sync
async maybeSync() {
const meta = await getSyncMeta(this.examSlugValue)
const lastSync = meta?.synced_at ? Date.parse(meta.synced_at) : 0
if (Date.now() - lastSync < SYNC_INTERVAL_MS) return
await this.runSync()
}
async runSync() {
const slug = this.examSlugValue
const res = await fetch(`/exams/${slug}/questions.json`, {
credentials: "same-origin",
headers: { "Accept": "application/json" },
})
if (!res.ok) return
const payload = await res.json()
await saveQuestions(slug, payload.questions || [], payload.locale)
console.info(`[offline-sync] saved ${payload.questions?.length || 0} questions for ${slug}`)
}
// handleOffline(), which shows the "N questions saved on this device" banner, is omitted
}It keeps the question data up to date in the background without the user having to think about it; you could call it a silent sync. Thanks to this, by the time a user wants to start offline practice, the question data is almost always already on the device.
3. Phase 4: Offline-Only Practice Mode
With the data layer in place from Phase 3, Phase 4 builds "a UI for solving questions offline" and "an answer queue + bulk sync."
3-1. Adding a Dedicated Offline Practice Page
Following the approach from §1, we leave the existing flow untouched and add a separate route, /exams/:slug/offline_practice. Since we're building one page in Rails, we prepare a controller, a view, JS, and routing as one set. Here are the pieces and the sections that cover them.
| Piece | File | Role | Covered in |
|---|---|---|---|
| Routing | routes.rb (two member routes) | Adds the URLs | §3-3 |
| Controller | OfflinePracticeController | Renders the page + receives answers | §3-3 |
| View | offline_practice/show.html.erb | An HTML shell containing three views: start / question / result | §3-4 |
| JS (Stimulus) | offline_practice_controller.js | Drives question display, grading, the answer queue, and sync | §3-4 |
| Data layer | db.js / question_store.js / attempt_queue.js | Reads and writes questions and answers in IndexedDB (built in Phase 3) | §3-2 |
In terms of behavior, the page works like this:
- Questions are served from IndexedDB (no trip to the server)
- The screen is an HTML shell + client-side rendering
- Answers aren't sent on the spot; they're queued in IndexedDB and sent together later
- Grading happens on the client on the spot → re-evaluated by the server at sync time
3-2. Storing Answers in IndexedDB Too
Answers made offline need to be kept on the device until it comes back online and syncs. We store these in IndexedDB as well, in a dedicated attempts_queue store.
That means that in addition to the "code that reads and writes questions" from §2-2, this "code that queues answers" also uses the same IndexedDB. In other words, there are now two pieces of code using the same DB.
In IndexedDB, stores can only be created inside the initialization that runs once, the first time (or when the version is bumped). If two modules each open the DB on their own, only the initialization of whichever opens it first runs, and the stores the other one was supposed to create never get created.
So we consolidated the store definitions and the DB-opening code in one place, app/javascript/offline/db.js, and both modules get the same DB from there. That way initialization runs only once, and all the stores are in place.
Adding a store (attempts_queue) requires bumping the IndexedDB version. We had already shipped v1 (questions + meta) as of Phase 3, so we bump to v2 and migrate both existing users (who have v1) and new users with a single piece of code.
// app/javascript/offline/db.js
const DB_NAME = "app"
const DB_VERSION = 2
let dbPromise = null
export function openDB() {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = event.target.result
const oldVersion = event.oldVersion
if (oldVersion < 1) {
// v1 schema (questions + meta)
}
if (oldVersion < 2) {
// added in v2: attempts_queue (autoIncrement key)
const attempts = db.createObjectStore("attempts_queue", {
keyPath: "id", autoIncrement: true
})
attempts.createIndex("exam_slug", "exam_slug", { unique: false })
attempts.createIndex("attempted_at", "attempted_at", { unique: false })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
return dbPromise
}The key is writing oldVersion < 1 and oldVersion < 2 as independent if statements. New users (v0 → v2) pass through both ifs, while existing users (v1 → v2) only run the v2 diff. A single piece of code covers both kinds of users. Caching dbPromise at module scope keeps it to a single connection no matter how many times openDB() is called.
On top of the shared db.js, we build attempt_queue.js to queue answers. Because we use an autoIncrement: true id, we get a device-local id scheme that's never sent to the server. Once a sync succeeds, entries are removed from the queue by that id.
// app/javascript/offline/attempt_queue.js
import { withStore, ATTEMPTS_QUEUE_STORE } from "offline/db"
export async function enqueueAttempt({
examSlug, questionId, selectedAnswers, correct, timeSpentSeconds
}) {
return withStore(ATTEMPTS_QUEUE_STORE, "readwrite", (store) =>
new Promise((resolve, reject) => {
const request = store.add({
exam_slug: examSlug,
question_id: questionId,
selected_answers: selectedAnswers,
correct: !!correct,
time_spent_seconds: timeSpentSeconds || null,
attempted_at: new Date().toISOString(),
})
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
)
}
// countQueuedAttempts / getQueuedAttempts / removeAttempts follow the same patternIt's important to record the time the question was answered in attempted_at. There can be a gap between solving questions offline and syncing, so we keep the time it was solved, not the time it was synced. The server reflects this in created_at (§3-3).
3-3. Server Side: A Bulk Sync Endpoint
Next, we build bulk_create to receive the queued answers. There are two routes: a GET that renders the offline practice page, and a POST that receives the queued answers.
# config/routes.rb
get :offline_practice, to: "offline_practice#show", on: :member
post :offline_attempts, to: "offline_practice#bulk_create", on: :member# app/controllers/offline_practice_controller.rb
def bulk_create
items = Array(params[:attempts])
saved = skipped = 0
Attempt.transaction do
items.each do |item|
question = @exam.questions.find_by(id: item[:question_id])
next (skipped += 1) unless question
attempt = current_user.attempts.new(
question: question,
selected_answers: Array(item[:selected_answers]).reject(&:blank?),
time_spent_seconds: item[:time_spent_seconds].presence&.to_i,
)
# keep the actual time answered offline in created_at
attempt.created_at = item[:attempted_at] if item[:attempted_at].present?
attempt.save ? (saved += 1) : (skipped += 1)
end
end
render json: { ok: true, saved: saved, skipped: skipped }
endTwo points to note here.
- The server re-grades everything: the
correctvalue sent by the client isn't trusted; theAttemptmodel (before_save) grades it again on save - One bad record doesn't stop the whole sync: invalid entries such as a bad
question_idare simply skipped and counted. The rest are saved together in anAttempt.transaction
3-4. Building the Offline Practice Screen
The offline practice screen places three views (start / question / result) within the same page in show.html.erb, and the Stimulus controller switches between them with classList.toggle("hidden"). We don't use Turbo Frames. Turbo Frames assume a round trip to the server, which makes them a poor fit for a screen that has to work offline.
| View | When shown | Contents |
|---|---|---|
| start | Before starting practice | "N questions available" + "Start practice" |
| question | While solving a question | Question text, choices, and "Submit answer" |
| result | After answering | Correct / Incorrect, and "Next question" |
Data is passed from the server to the controller via data-* attributes. We embed the sync URL, the CSRF token, i18n strings, and so on.
<div data-controller="offline-practice"
data-offline-practice-exam-slug-value="<%= @exam.slug %>"
data-offline-practice-sync-url-value="<%= offline_attempts_exam_path(@exam) %>"
data-offline-practice-csrf-token-value="<%= form_authenticity_token %>"
data-correct-label="<%= t("pwa.offline.practice.correct") %>">
<!-- the three views: start / question / result -->
</div>Here's the processing flow of the controller (offline_practice_controller.js).
connect(): reads questions from IndexedDB and shows the number available / the number waiting to syncstart(): shuffles with Fisher-Yates and shows the first questionsubmit(): grades the selected choices locally witharraysEqual→ saves to IndexedDB withenqueueAttempt()→ shows the resultnext(): moves to the next question. When it reaches the end, it reshuffles and loopssync(): when the sync button is pressed, sends everything in bulk viaPOST /offline_attempts→ on success,removeAttempts()
Syncing is the process of sending queued answer data to the server once the device is back online. Rails' CSRF protection applies to this POST too, so we send the token passed via data-...-csrf-token-value in the code in the X-CSRF-Token header.
4. About the Offline Sync Implementation
In §3, we implemented sync using the online event on window and a "Sync" button. There is, in fact, another standard option for offline sync: the Background Sync API. If you register a sync event in the Service Worker, the browser detects when the network comes back and runs the sync for you automatically. We chose not to use it this time.
There are two reasons. The first is that iOS Safari doesn't support the Background Sync API. The app's main target is the iPhone, so we can't make something that doesn't work on iOS the primary path.
The second is a UX design decision. In a study app, users feel more at ease when they can tell when their answers reached the server. Rather than having Background Sync quietly sync behind the scenes, sync that's visible to the user (showing "N waiting to sync" and sending them with a "Sync" button, or auto-syncing when the device comes back online) is a better fit for this use case.
5. Verification
We verified the behavior with Claude Code (automated Playwright tests) and on a real iPhone. Here's what we checked.
- Playwright:
[offline-sync] saved N questions for sapappears in the console, and thequestionsstore in IndexedDB contains the corresponding number of records (225 questions locally / 423 in production) - Real iPhone: when offline, the "N questions saved on this device" banner appears, proof that fetching, storing, and offline detection all work
- Real iPhone: the full flow of solving offline → "N unsynced" counter goes up → back online → "Sync now" makes the banner disappear
- Sync is designed to clear the queue only on success (200), so we can say with confidence that "the banner disappearing = recorded in
attempts"
6. Gotchas and Decision Log
6-1. Module Specifier Fails to Resolve Because importmap Wasn't Updated
Trying to import offline/question_store stopped with Failed to resolve module specifier. importmap-rails only resolves modules you've explicitly pinned, so you need to add pin_all_from "app/javascript/offline", under: "offline" to config/importmap.rb. You can check what's actually pinned with bin/importmap json. If you're used to bundlers like Webpack, it's easy to assume "drop a file in and you can import it," but importmap doesn't work that way.
6-2. The Difficulty of Testing a DB Version Bump
When bumping the IndexedDB schema from v1 → v2, there are two paths to verify: "new users (v0 → v2)" and "existing users (v1 → v2)." The former is easy to check with a clean start via DevTools → Application → IndexedDB → Delete database. The latter is the problem: you can't reproduce it without opening the app once with the v1 code and then swapping in the v2 code. In a production deploy, every existing user goes through that path, so not cutting corners and running the reproduction test in dev is what prevents incidents.
7. What Phase 3+4 Changed, and What's Next
In Phase 1+2, the app could only "open offline"; with Phase 3+4 in place, you can actually study offline.
- Even in airplane mode or on the subway, you can solve questions stored in IndexedDB
- "Next question" works, and with no server round trip, it's actually snappier than online
- Answers made offline are queued on the device and synced in bulk when you're back online
- The existing server-driven sessions haven't changed at all; the two routes divide the roles and coexist
At this point, "open the app and you can study" is complete. What's left is having the app itself give users a reason to open it; in other words, notifications. In the next and final installment, Phase 5, we implement Web Push notifications, covering VAPID key management, the iOS 16.4+ standalone mode restriction, daily delivery with Solid Queue, and automatic cleanup of dead subscriptions. Continue with the sequel: Implementing Web Push Notifications in Rails 8.
Afterword
This article documents how we added "offline features you can actually use" to a Rails 8 PWA. Technically, the core is IndexedDB, but what we really wanted to convey is the design decision: "rather than rebuilding the existing server-driven UI as an SPA, add offline support as a separate, independent route." Offline support can easily turn into a massive project that "rebuilds the whole app," but if you clearly limit its role and let it divide the work with what's already there, you can add it step by step without breaking the existing UX at all.
At MOOBON, we take on new Rails / web application development, feature additions to existing projects, and design for PWA conversion and offline support. We also welcome requests such as "we want to add offline features to our existing Rails app while limiting the impact" or "we'd like our IndexedDB or Service Worker design reviewed." Feel free to reach out at info@moobon.jp.
Frequently Asked Questions
QWhy didn't you add offline support by turning the existing study_session flow into an SPA?
The existing study session (study_session) is designed to compute "the next question to show" on the server every time. The logic that optimizes question selection based on the user's accuracy and weak areas lives on the Rails side, and every "Next question" assumes a round trip to the server. Making that work offline would mean porting the question-selection logic to the client as well, an SPA-level rewrite with far too large a blast radius. In this project, we didn't touch the existing flow at all and instead added a separate route, /exams/:slug/offline_practice. We deliberately limited offline practice to "light review with randomly shuffled questions," and for serious studying you use the regular sessions; that's the division of roles. The benefits are zero impact on the existing UX and the ability to grow the feature independently.
QWhy didn't you use an IndexedDB library like idb or Dexie?
IndexedDB has an old, non-Promise-based API, so wrapping it in a library is the norm. But this project only deals with three stores (questions / meta / attempts_queue), and the operations needed are little more than "put all," "get all," and "conditional delete." Wrapping helpers called openDB and withStore in Promises came to about 50 lines, schema definition included. In an importmap-rails setup, adding npm dependencies also affects the build configuration, so at this scale we judged that a hand-rolled wrapper keeps dependencies down and is easier to follow. If the number of stores or the complexity of operations grows, we'll consider moving to idb at that point.
QIsn't it a security problem to include correct_answers in the JSON payload?
We include correct_answers in the payload so answers can be graded locally offline. That does mean "anyone who opens DevTools can see the answers," but this is information that's shown after you submit an answer anyway, not something confidential. The copyright treatment of the exam questions themselves needs separate consideration, but we concluded there's little technical point in hiding the answer data. Conversely, information that genuinely must be hidden (other users' data, billing information, and so on) naturally never goes into the payload. This is a case where the functional requirement of "self-grading offline" lines up with the nature of the data as "information there's no point in hiding."
QHow did you test the IndexedDB schema upgrade (v1 → v2)?
In IndexedDB, you write incremental migrations inside onupgradeneeded by checking oldVersion (the if oldVersion < 1 / if oldVersion < 2 pattern). There are two paths to verify in testing: (1) new users (going straight from v0 to v2), and (2) existing users (only running the v1 → v2 diff). During development, we recreated the v0 state via DevTools → Application → IndexedDB → Delete database to check (1), and for (2) we opened the app once with the v1 code and then swapped in the v2 code. In a production deploy, every existing user goes through path (2), so always reproducing it in dev is what prevents incidents.
QWhat happens to answers made offline if the user switches devices?
Answers made offline are stored locally in the attempts_queue store in that device's IndexedDB. When the device comes back online, they're synced to the server in bulk and removed from the queue on success. If the user switches to another device or uninstalls the PWA before syncing, those unsynced answers are lost. That's a deliberate design choice to treat offline answers as temporary, device-local data; once synced, they're stored as proper records in the server-side attempts table. In practice, "press the sync button soon after getting back online" and "auto-sync on app launch" cover the vast majority of cases. If real-time sync across multiple devices becomes a requirement, we'll revisit the design at that point.
