Introduction: Why We Chose a PWA to Put Our Prototype Study App on the Home Screen
To study for the AWS Solutions Architect Professional (SAP) exam, we're prototyping an in-house Rails 8 app: a study app where you solve certification exam questions and get explanations written with Claude. The explanations are generated with Claude ahead of time and stored, and for now we're using the app for our own studying to see how well it works.
After using it for a bit, the first thing that stood out was that the flow of "open a browser, jump in from a bookmark, and log in every single time" was way too much friction. It's hard enough to get motivated to study in spare moments as it is... honestly, it just wasn't going to happen.
So we decided to turn the study app into a PWA (Progressive Web App). The goal: make it pleasant to use on mobile!
- It opens instantly when you tap the home screen icon
- You stay logged in
- The URL bar and navigation bar disappear so the app can use the full screen
- It works offline
In short, the work is all about making it feel like a real app.
We chose a PWA over native (Swift / React Native) because this is an internal tool, and Rails + PWA was more than enough.
The PWA implementation is split into three articles; this one covers Phase 1 (minimal PWA) + Phase 2 (app shell caching). It's written for engineers building web apps with Rails 8 and developers weighing whether to give up on native and go all-in on a PWA. We share the reasoning behind our decisions and the pitfalls we hit, along with generalized code samples.
1. Implementing a PWA in Rails 8: Prerequisites and Task List
A PWA is an ordinary web app with "installable" and "opens offline" added on top. At a minimum, it consists of the following pieces.
- Web App Manifest: Metadata such as the app name, icons, and display mode. With it, the app can be added to the home screen and opened full-screen without a URL bar
- Service Worker: A script that sits between the page and the network. It intercepts requests and returns cached or offline responses (Web Push also lives here)
- Icons: Images in various sizes for the home screen and splash screen
- HTTPS: A prerequisite for Service Workers to run (localhost is the exception)
1-1. Prerequisites: What Rails 8 Gives You
Right after rails new, the PWA groundwork is already in place up to this point.
app/views/pwa/
├── manifest.json.erb # manifest (ERB supported)
└── service-worker.js # Service Worker- A directory for the templates (the two files above in
app/views/pwa/): though they're basically empty placeholders that we'll fill in - An implementation of
Rails::PwaController: it servesrender template: "pwa/..."withlayout: false, so you don't need to write a controller in your app - ERB preprocessing for the manifest: so the manifest can be generated dynamically
1-2. Implementation Task List
From here, we'll work through the tasks needed to get it running as a PWA, in order. For each one, we also note what the task is for.
- Register the routes: make
/manifestand/service-workerservable (without this, requests never reachRails::PwaController) - Write the manifest: add the app name, display mode, and icon definitions so the app can be added to the home screen and opened full-screen
- Prepare the icon images (192 / 512 / maskable / apple-touch-icon): polish how the app looks on the home screen, the splash screen, and iOS
- Add links and meta tags to the HTML head: let the browser pick up the manifest, icons, and theme color
- Show an install prompt UI: bridge the differences between iOS and Android and nudge users to install
- Implement the Service Worker: cache the app shell so the app opens even offline
2. Phase 1: Building a Minimal PWA
The goal of Phase 1 is to get to the point where the app can be added to the home screen, launches in standalone mode on tap, and shows an install prompt. The Service Worker can still be empty at this stage (we'll flesh it out in Phase 2).
2-1. Registering the Routes
The PWA endpoints aren't generated in config/routes.rb, so add these two lines by hand. This wires /manifest and /service-worker to Rails::PwaController, which serves the templates in app/views/pwa/.
# config/routes.rb
get "manifest" => "rails/pwa#manifest",
as: :pwa_manifest, defaults: { format: "json" }
get "service-worker" => "rails/pwa#service_worker",
as: :pwa_service_worker2-2. Fleshing Out manifest.json.erb
Freshly generated, manifest.json.erb is full of placeholders, as shown below.
{
"name": "App",
"icons": [
{ "src": "/icon.png", "type": "image/png", "sizes": "512x512" },
{ "src": "/icon.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "App.",
"theme_color": "#1a1a2e",
"background_color": "#1a1a2e"
}Here's the version we rewrote for our app (swap in your own colors and names as you read). The app's primary language is Japanese, so name, short_name, and description are in Japanese; they read roughly "Certification Study App (Past-Exam Practice)," "Certification Study App," and "A study app where you solve certification exam questions and Claude AI breaks down the explanations."
{
"name": "資格学習アプリ(過去問演習)",
"short_name": "資格学習アプリ",
"description": "資格試験の問題を解き、Claude AI が噛み砕いて解説する学習アプリ。",
"lang": "ja",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"id": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#1a1a2e",
"background_color": "#1a1a2e",
"categories": ["education", "productivity"],
"icons": [
{ "src": "/icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" },
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" },
{ "src": "/icon-maskable-192.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" },
{ "src": "/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}Five points worth calling out:
- Set
idexplicitly ("/"for an app at the root): this is the app's unique identifier. Pinning it means that even if you changestart_urllater (during maintenance, for example), the app is installed over the existing one as the same app. categories: a hint for classification in app stores and the like (see the FAQ for allowed values)orientation: specifies screen orientation. Portrait-only is natural for a study app (see the FAQ for iOS behavior and the full list of values)- Split icons into
any/maskable: supporting Android Adaptive Icons requires a dedicated image with built-in padding formaskable - Match
theme_color,background_color, and the icon background: a deliberate choice to get rid of visible seams on the splash screen (more in 5-2)
2-3. Preparing Five Icons and Maskable Support
A PWA needs roughly five kinds of icons. These days, AI tools can generate them for you with little effort.
| File | Size | Purpose |
|---|---|---|
| icon-192.png | 192×192 | Android standard |
| icon-512.png | 512×512 | Android large / splash screen |
| apple-touch-icon.png | 180×180 | iOS Safari home screen |
| icon-maskable-192.png | 192×192 | Android Adaptive Icon |
| icon-maskable-512.png | 512×512 | Android Adaptive Icon (large) |
What is a maskable icon?
maskable icons are part of Android's Adaptive Icon system: the OS masks the icon into whatever shape it likes (circle, squircle, teardrop, etc.) and displays it on the home screen. The icon's content (logo or text) must fit inside the central 80% circle, and the outer 10% is a safe margin that may be cropped. If you hand over a plain "logo only" image, the edges get clipped or the center looks sparse.
In this project, we took our existing icon.png (1024×1024, #1a1a2e background with the kanji 解, meaning "solve," as the logo in the center) and generated the maskable version with a single ImageMagick command: resize to 80% → extend back to 100% with a background of the same color.
# example: generate a 512×512 maskable icon
magick icon.png \
-resize 410x410 \
-background "#1a1a2e" \
-gravity center \
-extent 512x512 \
icon-maskable-512.pngThe logo is already rasterized in the base PNG, so no text rendering is needed. This is actually "the result of working around the path we tried first and got stuck on"; the details are in §5-1.
2-4. Meta Tags in the HTML head
Add the following seven lines to the <head> of app/views/layouts/application.html.erb (the apple-mobile-web-app-title value is the Japanese app name, "Certification Study App").
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="<%= pwa_manifest_path %>">
<meta name="theme-color" content="#1a1a2e">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="資格学習アプリ">The apple-mobile-web-app-* tags are an old iOS-only proprietary spec, but they're still in use (mobile-web-app-capable is the standard version, so we include both). Choosing apple-mobile-web-app-status-bar-style: black-translucent makes the iOS status bar translucent so content extends across the entire screen. Matching it with theme_color gives a cohesive look.
This is also where we register the SW. We wait for window.load so it doesn't delay First Contentful Paint, and we set scope: "/" explicitly to prevent it from operating only on a sub-path.
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("<%= pwa_service_worker_path %>", { scope: "/" })
.catch(() => {})
})
}
</script>CSP is disabled in this project, so an inline script is fine, but if you enable CSP you'll need to move it to an external JS file or use a nonce (see the FAQ).
2-5. Install Prompt UI (Bridging iOS / Android Differences)
The install flow differs significantly between iOS and Android.
- iOS Safari: never fires the
beforeinstallpromptevent. The only option is to have users go through "Share menu → Add to Home Screen" manually - Android Chrome / Edge: fires
beforeinstallprompt, so you can defer it withpreventDefault()and callprompt()from your own button whenever you like
We bridged this gap with a Stimulus controller (app/javascript/controllers/install_prompt_controller.js). Here's the core logic.
import { Controller } from "@hotwired/stimulus"
const DISMISS_KEY = "app.installPromptDismissedAt"
const DISMISS_DAYS = 14
export default class extends Controller {
static targets = ["iosMessage", "androidMessage", "installButton"]
connect() {
if (this.isStandalone() || this.isDismissed()) {
this.element.remove()
return
}
if (this.isIOS()) {
this.iosMessageTarget.classList.remove("hidden")
this.element.classList.remove("hidden")
} else {
this.deferredPrompt = null
this.beforeInstallHandler = (event) => {
event.preventDefault()
this.deferredPrompt = event
this.androidMessageTarget.classList.remove("hidden")
this.installButtonTarget.classList.remove("hidden")
this.element.classList.remove("hidden")
}
window.addEventListener("beforeinstallprompt", this.beforeInstallHandler)
}
}
async install() {
if (!this.deferredPrompt) return
this.deferredPrompt.prompt()
const { outcome } = await this.deferredPrompt.userChoice
this.deferredPrompt = null
if (outcome === "accepted") this.element.remove()
else this.dismiss()
}
dismiss() {
localStorage.setItem(DISMISS_KEY, String(Date.now()))
this.element.remove()
}
isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true
)
}
isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
}
isDismissed() {
const at = parseInt(localStorage.getItem(DISMISS_KEY) || "0", 10)
if (!at) return false
const days = (Date.now() - at) / (1000 * 60 * 60 * 24)
return days < DISMISS_DAYS
}
}The key is the three-stage early return. (1) If the app is already running in standalone mode (display-mode: standalone or navigator.standalone), there's nothing left to prompt for, so the whole banner is remove()d. (2) If it was dismissed within the last 14 days, it's removed the same way. (3) Only after passing both checks do we branch into iOS / Android.
The copy lives in i18n keys (such as pwa.install.ios_message) and is available in three languages: ja / en / vi. In the layout, the banner sits right after <main> and is pinned to the bottom center with fixed bottom-4 left-1/2 -translate-x-1/2 z-40. It starts out completely invisible via the hidden class and only appears once the controller decides it should.
3. Phase 2: Implementing the Service Worker
The goal of Phase 2 is to implement the Service Worker so that it satisfies these two requirements.
- No errors when offline: instead of stopping at the browser's "No internet connection" screen, show previously opened pages from the cache and return our own
offline.htmlfor pages that haven't been visited yet - Apply an updated Service Worker immediately: once a fix is deployed, make sure the new SW reliably takes effect on the next launch
Offline support for the question data itself is handled in Phase 3 (IndexedDB), so it's out of scope here.
3-1. How a Service Worker Works
A Service Worker (SW from here on) is a script that acts as a middleman between the page and the server. Once registered, it gets first crack at the requests the page makes (for pages, images, and data), so it can step in and, say, "answer from the cache before going to the server." It can also run in the background even when the page is closed, which is where things like receiving push notifications are handled. The reason it can work offline is that files are stored in a cache on the device, and when there's no network the SW serves them from there. "What to store, when, and how to serve it" is decided at each point in the SW's lifecycle.
- install: an initialization event that runs exactly once after registration. This is where precaching (fetching core files ahead of time) happens
- activate: when the SW becomes active. This is where you delete caches from old versions (by putting a version in the cache name and keeping only the current generation)
- fetch: from then on, the SW receives the page's requests and responds according to its caching strategy. Anything used gets stored in the runtime cache (cached as you go) here
3-2. What to Store Up Front (Precache) vs. on the Fly (Runtime)
There are two broad approaches to Service Worker caching. Precaching means storing the files you need ahead of time, when the Service Worker is installed. Runtime caching means storing what the user actually loads as they go and reusing it from then on. This section is about deciding "what to secure up front and what to accumulate as you go."
Rails 8 serves static files such as CSS / JS, images, and fonts (collectively, "assets") via Propshaft + importmap. Of these, CSS / JS get a new URL on every deploy (digest-stamped, e.g., /assets/application-abc123.css), so hard-coding them into the precache list makes it stale almost immediately. So we only precache things with stable URLs and leave anything that changes to the runtime cache.
- Precache (stable URLs):
/offline.html,/manifest, and the icons (svg / 192 / 512 / two maskable / apple-touch-icon, six files in total) - Runtime (changing URLs): CSS / JS / fonts. Picked up by the
fetchhandler on first load, then served with stale-while-revalidate (the cache naturally keeps up even when digest URLs change)
These three kinds of precached files are the foundation you always need, even offline. Thanks to them, icons and the splash screen never go missing without a network connection, and if the user lands on an uncached page, /offline.html is shown instead. Meanwhile, the runtime side naturally accumulates "pages you've viewed and assets you've loaded," so they display offline on revisit (/offline.html is just a safety net for uncached pages).
3-3. Basic Implementation and Offline Setup
Building on 3-1 (how the SW works) and 3-2 (what to store), we first implement the basic version. This is the body of app/views/pwa/service-worker.js. We leave out skipWaiting, which applies updates immediately, for now and add it in 3-5.
const VERSION = "v3"
const PRECACHE = `app-precache-${VERSION}`
const RUNTIME = `app-runtime-${VERSION}`
const OFFLINE_URL = "/offline.html"
const PRECACHE_URLS = [
OFFLINE_URL,
"/manifest",
"/icon.svg",
"/icon-192.png",
"/icon-512.png",
"/icon-maskable-192.png",
"/icon-maskable-512.png",
"/apple-touch-icon.png",
]
// install: precache the core files
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS))
)
})
// activate: delete caches from old versions
self.addEventListener("activate", (event) => {
const allowed = new Set([PRECACHE, RUNTIME])
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => !allowed.has(k)).map((k) => caches.delete(k))
)
)
)
})
// fetch: route by request type
self.addEventListener("fetch", (event) => {
const { request } = event
if (request.method !== "GET") return // let writes (POST, etc.) pass through
const url = new URL(request.url)
if (url.origin !== self.location.origin) return // let cross-origin requests pass through
// Turbo navigations are fetch() calls, not native browser "navigate" requests,
// so also treat GETs whose Accept header includes text/html as HTML page requests.
const acceptsHtml = (request.headers.get("Accept") || "").includes("text/html")
if (request.mode === "navigate" || acceptsHtml) {
event.respondWith(networkFirstNavigation(request)) // network first → cache → offline.html
return
}
if (isStaticAsset(url)) {
event.respondWith(staleWhileRevalidate(request)) // serve from cache, refresh in background
}
})
async function networkFirstNavigation(request) {
try {
const response = await fetch(request)
const cache = await caches.open(RUNTIME)
cache.put(request, response.clone())
return response
} catch (_e) {
const cached = await caches.match(request)
if (cached) return cached
const offline = await caches.match(OFFLINE_URL)
return offline || new Response("Offline", { status: 503 })
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME)
const cached = await cache.match(request)
const networkFetch = fetch(request)
.then((response) => {
if (response && response.ok) cache.put(request, response.clone())
return response
})
.catch(() => null)
return cached || (await networkFetch) || new Response("Offline", { status: 503 })
}
function isStaticAsset(url) {
if (url.pathname.startsWith("/assets/")) return true
if (url.pathname === "/manifest") return true
if (/\.(css|js|svg|png|jpg|jpeg|gif|webp|ico|woff2?)$/i.test(url.pathname)) return true
return false
}The fetch routing rests on two pillars: "pages (HTML) go network-first → fall back to the cache → and finally to offline.html" and "assets are served straight from the cache and refreshed in the background" (cross-origin requests and POSTs pass straight through). See the comments in the code for what each line is doing.
Versioning is handled with the VERSION constant. Whenever you change the SW logic, bump it by hand: the old version's caches are cleared in the activate phase, and existing users get the new logic too. Note that the acceptsHtml line in the fetch handler above wasn't in the code we first wrote; it's a fix for a bug we found during testing. The next section, "The Turbo Pitfall," tells that story.
mode === "navigate" isn't enough on its own: in a Rails app, a SW that relies on it alone to detect HTML page requests will almost always come up short.
Our first draft of the fetch handler followed the typical PWA tutorial and checked only request.mode === "navigate". But when we verified offline navigation with the Playwright tests in §4, we found that no page other than the top page made it into the runtime cache, so even pages we'd already opened online wouldn't load offline (we later confirmed the same symptom in airplane mode on a real iPhone).
The culprit was Hotwire/Turbo. Turbo page transitions are fetch() requests, not native browser navigations, and their request.mode is never "navigate".
- Loading
start_url(the top page) when the PWA launches = a native browsernavigate→ cached by the SW → viewable offline - Every Turbo navigation after that =
fetch(), with amodeother than"navigate"→ the SW'sfetchhandler ignores it entirely and lets it through → fails offline
Rails 8 uses Hotwire/Turbo by default, so this is a trap Rails developers can easily fall into. The fix is to also treat GETs whose Accept header includes text/html as HTML page requests (acceptsHtml in the code above). The details of that testing and verifying the fix are covered in §4.
We made the offline fallback, public/offline.html, a static HTML file that doesn't go through Rails (served by Propshaft). It uses no Tailwind, only an inline <style>, keeping the structure simple enough that the SW can reliably precache it. The page text is the app's Japanese UI: it says "You're offline," "You're not connected to the internet. Please try again once your connection is back," and has a "Reload" button.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>オフライン - 資格学習アプリ</title>
<meta name="theme-color" content="#1a1a2e">
<style>
body { /* inline styles */ }
/* ... omitted ... */
</style>
</head>
<body>
<div class="card">
<div class="icon">解</div>
<h1>オフラインです</h1>
<p>インターネットに接続できていません。<br>接続が戻ったら、もう一度お試しください。</p>
<button type="button" onclick="location.reload()">再読み込み</button>
</div>
</body>
</html>The whole point of this page is to "reliably return a bare-minimum response," so keeping dependencies to a minimum is key. If it inherited the app's layout, there'd be a higher risk that, once you actually go offline, the layout CSS or fonts fail to load and the page looks broken.
3-4. Applying an Updated Service Worker Immediately
The basic version works as is, but we had one more requirement: "apply an updated SW immediately." The tricky part is how things behave when you update (deploy) the SW. When you change the SW file and deploy it, the browser installs the new SW, but it doesn't take effect right away. By spec, the new SW first enters a "waiting" state and isn't activated until every tab and window controlled by the old SW has been closed.
This is where a harsh reality kicks in: an installed PWA is almost never "fully closed." An app opened from the home screen just gets sent to the background, so the old SW can hang around for days, and fixes you put into the SW (to caching strategy or offline behavior) never reach users. For a use case like a study app, where you want bug fixes and display changes applied on the very next launch, that's frustrating behavior.
3-5. Implementing skipWaiting / clients.claim
So we add skipWaiting() (don't wait during the install phase) and clients.claim() (take control of existing tabs during the activate phase) to the basic version from 3-3. The diff touches two places.
self.addEventListener("install", (event) => {
event.waitUntil(/* precache */)
self.skipWaiting() // ← added: don't wait for existing tabs to close
})
self.addEventListener("activate", (event) => {
event.waitUntil(
/* delete old caches */
.then(() => self.clients.claim()) // ← added: take control of existing tabs too
)
})The trade-off is that on long-running screens (for example, if a new SW is deployed while someone is in exam mode), there's a risk of old CSS / JS getting mixed with new HTML. Since this is an internal system used by a handful of people, we accepted that.
4. Verification: Testing, Finding a Bug, and Fixing It
As it happens, we only noticed the Turbo bug (the sidebar in §3-3) in the middle of this verification. When we wrote the code, we assumed it would work, but as we checked things step by step (server side → automated Playwright tests → a real iPhone), problems we couldn't see on paper started to surface. Here's exactly what we did.
4-1. Checking the Server-Side Endpoints
It's unglamorous, but first we ran through every URL in the precache list with curl to confirm each one returns 200 with a sensible size. If even one returns a 404 or 500, the entire precache fails no matter how correct the SW code is, so it pays to rule that out first. The result: all 8 files returned 200, about 140 KB in total. That's small enough to fetch once at first install and then run fully offline.
4-2. Automating SW Offline Tests with Playwright
Checking offline behavior for Turbo navigations by hand turned out to be surprisingly tedious. Doing "warm up the cache, go offline, click a link..." manually every time just isn't practical. So we automated it with playwright-core and a local Chrome (we left writing and running the tests to Claude Code). Here's the flow.
- Log in as a test user
- Wait until the SW is
activatedvianavigator.serviceWorker.getRegistration(), then reload so the SW controls the page - Click the nav links (= Turbo navigations) to visit the guide, offline practice, and study pages
- Actually enumerate the contents of the runtime cache with
caches.keys()/cache.keys()to check whether the HTML pages reached via Turbo navigation were cached - Go offline with
context.setOffline(true) - Click the nav links again and check whether each page's unique marker string shows up, or whether it falls back to
offline.html
When we ran this test against the "first version we wrote" from §3-3 (mode === "navigate" only), sure enough, none of the Turbo navigation targets made it into the cache, and everything failed offline. After switching to the version with the acceptsHtml check, /exams/sap/guides, /exams/sap/offline_practice, and /exams/sap/sessions/new all landed in the runtime cache as expected, and their content showed up offline.
4-3. Testing on a Real iPhone and Rolling Over SW Versions
The automated tests pass at this point, but with PWAs there's a lot you can't know without trying a real device, so the final step is to use production (custom domain, HTTPS) on an iPhone.
- Open the production URL in Safari (on iOS, Add to Home Screen doesn't work properly outside Safari)
- Share menu → "Add to Home Screen" → the name field is pre-filled with the manifest's
short_name - Tap the home screen icon (解 on a navy background) → it launches standalone with no address bar
- Browse a few pages online (to warm up the SW cache)
- Turn on airplane mode → pages you've visited are displayed / unvisited pages show
offline.html
A caveat when updating the SW
When you update the Service Worker (bump VERSION), the activate handler deletes the old version's caches, so the runtime cache is empty right after an update. When checking offline behavior, be sure to go in this order: "activate the new SW → browse once online to fill the cache → then go offline." Otherwise you won't see the behavior you supposedly fixed.
5. Gotchas and Decision Log
5-1. ImageMagick's Ghostscript Error
At first, we created a dedicated SVG for the maskable icon (a version with the rounded corners removed and extra padding) and tried to convert it to PNG with ImageMagick.
magick -background "#1a1a2e" icon-maskable.svg -resize 512x512 icon-maskable-512.pngBut it stops with an error.
sh: gs: command not found
magick: delegate library support not built-in 'none' (Freetype)ImageMagick's SVG text rendering depends on the Ghostscript and Freetype delegates. The Homebrew build of ImageMagick doesn't include them by default.
The workaround was simply to switch to processing the original icon.png (1024×1024) directly. The logo is already rasterized in the base PNG, so no text rendering is needed, and -resize 80% + extent alone gives it the padding a maskable icon needs (see §2-2).
When you do need SVG → PNG, macOS's built-in sips or rsvg-convert (librsvg) is more reliable. This is one of those areas where thinking "ImageMagick can do anything" and digging deeper tends to eat up your time.
5-2. Why theme_color Matches the Icon Background
Generally speaking, theme_color is used for the color of the navigation bar and status bar, and the usual choice is the app's brand color (in this app's case, the indigo-600 used in the nav bar).
However, a PWA's splash screen (the screen shown after tapping the icon, until the main content renders) is drawn from the combination of theme_color and background_color. If the icon's background color differs from theme_color / background_color, you can see the edge of the icon's background when it's placed on the splash screen, which looks cheap.
In this app, we set the icon background to #1a1a2e and made theme_color and background_color the same color. It's a different shade from the nav bar's indigo-600, but we prioritized a seamless splash screen. Combined with apple-mobile-web-app-status-bar-style: black-translucent, the iOS status bar blends in too, and nothing looks out of place.
5-3. Why the Manifest Is Precached
/manifest is static JSON served via Rails::PwaController, so it behaves the same whether or not it ends up in the runtime cache. Even so, we explicitly included it in the precache. The reason is to guarantee that the app never has to go over the network to fetch it when being installed as a PWA.
Realistically, few users will start the "Add to Home Screen" flow while offline, but it avoids the mishap of stumbling on the manifest fetch when someone has just started using the app on a spotty connection, like on the subway or a plane. For the cost of one extra line in the precache list, the app's identity information, locked in when the SW is first installed, no longer depends on network conditions later on.
6. What Phase 1+2 Changed, and What's Next
With Phase 1+2 in place, the study app went from "a Rails app running in the browser" to "a Rails app that looks like an app sitting on your home screen." Here's a concrete summary of what we gained.
- It launches standalone directly from the home screen icon (no URL bar / nav bar, so more screen space)
- You can pick up where you left off without losing your login session (cookies carry over to standalone mode)
- Navigating while offline shows
offline.htmlinstead of a blank error - From the second launch on, the app shell (CSS / JS / icons) is served from the cache
- On Android, the "Add to Home Screen" prompt can be shown whenever we choose
On the other hand, there are also things Phase 1+2 can't do yet. These are covered in the follow-up articles.
- Solving questions offline: the question data itself still depends on the server. Covered in Phase 3 (IndexedDB)
- Syncing answers made offline later: answer in airplane mode → send once you're back online. Phase 4 (an answer queue + sync on reconnect)
- Study reminder notifications: a daily reminder and the like. Phase 5 (Web Push notifications / VAPID key generation / iOS 16.4+ support)
From Phase 3 on, everything builds on the app shell foundation from Phase 1+2 (the networkFirstNavigation fallback structure and the precache / runtime cache split). We've published the follow-ups, Part 2: Making a Rails 8 PWA Work Offline (Phase 3+4) and Part 3: Implementing Web Push Notifications in Rails 8 (Phase 5), so you can keep reading straight through once you finish this one.
Afterword
This article documents how we took the PWA scaffold that Rails 8 provides and built it out into a real PWA. We wrote it with an understanding of the Rails 8 way, "the manifest and Service Worker files are there by name, but they're almost empty" (the stance being: we'll put the framework in place, but the requirements are up to your project), and aimed to show, with concrete examples, one complete way to fill them in.
The study app is still an in-house prototype, and we're in the middle of testing whether it can get us through the AWS SAP exam. Turning it into a PWA is also part of an effort to make it that much more practical.
At MOOBON, we take on new Rails / web application development, enhancements to existing projects, and PWA conversion and mobile experience design. We also welcome requests for a second opinion on technical decisions, such as "we're torn between native and a PWA" or "we'd like advice on how to add a Service Worker to our existing Rails app." Feel free to reach out at info@moobon.jp.
Frequently Asked Questions
QWhy did you choose a PWA instead of native iOS / Android apps?
This certification study app is an internal prototype we're building to study for the AWS SAP exam, and the starting point was simply wanting to shorten the study flow from "browser → log in → study" to "tap the home screen icon → study." We skipped native for three reasons: (1) we didn't want to take on App Store review and the $99/year fee plus 30% commission this early in the project, (2) we wanted to support both iOS and Android from a single Rails codebase, and (3) we wanted to reuse what we already had as-is, such as streaming AI explanations via Turbo Streams. For a use case like certification study, a PWA covers most of the experience we need. If push notifications or deep OS integration become hard requirements, we've left room to revisit native then.
QiOS Safari doesn't fire beforeinstallprompt. What's a realistic way to handle that?
Because iOS Safari never fires beforeinstallprompt, you can't build the Android Chrome-style "custom button → native dialog" install flow. By design, the only option is to have users go through "Share menu → Add to Home Screen" manually. In this project, a Stimulus controller detects the platform and splits into two paths: on iOS it shows a text banner telling the user to open the Share menu and choose "Add to Home Screen"; on Android it defers beforeinstallprompt with preventDefault() and calls prompt() from our own button. If the user taps "Later," we store the dismissal time in localStorage and suppress the banner for 14 days. And if the app is already running in standalone mode (display-mode: standalone or navigator.standalone), the banner isn't shown at all.
QI set orientation to portrait in the manifest, but the app still rotates on iPhone. Why?
Orientation locking via the orientation member works in Android Chrome, but iOS Safari basically ignores it for home screen PWAs. So even with orientation set to portrait in the manifest, the app may follow the device and rotate to landscape on an iPhone. If you really need a portrait-only experience, don't rely on the manifest alone; design the CSS and layout around portrait as well (for example, guarding against broken layouts in landscape, or showing a "best viewed in portrait" notice when rotated). For reference, eight orientation values are defined: any / natural / portrait / portrait-primary / portrait-secondary / landscape / landscape-primary / landscape-secondary.
QWhat values can I use for categories in the manifest?
categories is an array of lowercase strings that classify the app. It's a hint for app stores and catalogs (optional, and whether it's honored depends on the catalog). The W3C maintains a list of known categories, including books / business / education / entertainment / finance / fitness / food / games / government / health / kids / lifestyle / magazines / medical / music / navigation / news / personalization / photo / productivity / security / shopping / social / sports / travel / utilities / weather. You can use arbitrary strings outside the list, but stores will fall back to their own classification. For a certification study app, education (or education, productivity if needed) is the natural choice.
QWhat is the maskable icon safe zone, and what's the easiest way to create one?
Maskable icons are part of Android's Adaptive Icon system: the OS masks the icon into any shape it likes (circle, squircle, teardrop, etc.) on the home screen. The icon's content (logo or text) must fit inside the central circle (80% of the diameter), and the outer 10% on each side is a safe margin that may be cropped. If you just hand over a plain logo image, the edges get clipped or the center looks sparse. In this project, we took our existing icon.png (1024×1024, #1a1a2e background with a logo in the center), resized it to 80% with ImageMagick, and then extended it back to 100% with a background of the same color. It's a single command (see below). Rendering SVG to PNG with ImageMagick requires the Ghostscript / Freetype delegates and can get you stuck, so starting from a PNG is far less error-prone.
QAre there risks in adding skipWaiting + clients.claim?
Yes. By default, a new Service Worker waits while the old SW keeps running until all existing tabs are closed. With skipWaiting + clients.claim, the new SW takes over existing tabs the moment it starts, so a long-lived page can end up mixing "DOM rendered with old CSS/JS" and "new HTML/SW." For this project (a study app with a limited number of users for now), we accepted that risk in favor of switching to new versions immediately. If the user base grows and long sessions (such as during exam mode) become the norm, we have the option of removing skipWaiting and going back to the straightforward behavior where the new version kicks in automatically after tabs are closed. It comes down to weighing the value of applying new versions immediately against the size of the mixed-version risk.
QOnce CSP (Content Security Policy) is enabled, how should the inline Service Worker registration script be rewritten?
This project has CSP disabled (config/initializers/content_security_policy.rb is fully commented out), so the SW registration is written inline as <script>...</script>. With CSP enabled, inline scripts require either allowing default-src 'unsafe-inline' or issuing a nonce. If you go with a nonce, generate it from the Rails controller layer via <%= csp_meta_tag %> and embed the script as <script nonce="<%= request.content_security_policy_nonce %>">...</script>. The cleaner option is to move the registration logic into a small JS file under app/javascript/ (e.g., pwa_registration.js) and ship it through an importmap-rails entry point. That leaves no inline script behind, so it holds up if you tighten CSP later.
