DOCS LLMs

RailsFast Native - Offline mode

A phone loses the internet constantly — a tunnel, a parking garage, a metro platform, a rural stretch of road. Out of the box, Hotwire Native answers that moment with a bare, unbranded, English "Error loading page / Host Lookup" screen, and on Android the only way to retry is an invisible pull-to-refresh. RailsFast replaces that with a branded, self-healing offline experience, split cleanly down the one line that matters: native owns failed full-page loads, the web layer owns everything that happens while a page is already on screen.

TIP

This ships wired into all three starter repositories. railsfast-ios and railsfast-android register a branded, self-healing error view for every web surface; railsfast-base mounts a connectivity pill in the app layouts. You customize copy and colors, not the plumbing — the rest of this page is the why.

The stock failure (what we're replacing)

The barren screen is the framework's own default error view, and it's genuinely hostile in three ways.

No brand, no reassurance, wrong language. On Android the layout is hotwire_error.xml — a ConstraintLayout with two MaterialTextViews, no logo, no icon, no button. Its title is the hotwire_error_message string, "Error loading page" (core/src/main/res/values/strings.xml), and its subtitle is error.description() — for a DNS failure while offline the WebView reports ERROR_HOST_LOOKUP, which maps to WebError.HostLookup with description = "Host Lookup" (WebError.kt:22-25). On iOS the equivalent is DefaultErrorView (SwiftUI): a triangle SF Symbol, "Error loading page", error.localizedDescription, and a plain "Retry" button (DefaultErrorView.swift).

The Android retry affordance is invisible. The stock error container is wrapped in a SwipeRefreshLayout whose listener calls refresh() — but there is no button and nothing tells the user to pull down. The only retry is a gesture nobody can see.

Nothing self-heals. The stock views don't watch connectivity. Come out of the tunnel and the error screen just sits there until the user pokes it — at the exact moment (mid-ride, mid-booking) they most need the app to look like it knows what's happening.

Both frameworks expose an official hook to replace the view, and Turbo dispatches JS events for the mid-session case. RailsFast builds entirely on those stable, documented surfaces — zero framework forks.

The split

One line divides the whole feature. If a full-page load fails (cold boot offline; a tap to a screen with no cached Turbo snapshot), there is no web page to run JS in — so native must own the failure. If a page is already on screen and the network drops under the user's finger (a frame load fails, a form submit dies, the tunnel swallows the signal mid-scroll), the page is alive and its JS runs — so the web layer owns it, once, for every platform at the same time.

Situation Owner Mechanism
Failed full-page load (cold boot offline, tap with no snapshot) Native makeCustomErrorView (iOS) / createErrorView (Android) → branded, self-healing error screen
Transient loss while a page is visible (tunnel, dead zone) Web railsfast--offline-banneronline/offline window events
A frame or Visit fetch fails Web railsfast--offline-bannerturbo:fetch-request-error
A form submit fails at the network layer Web railsfast--offline-bannerturbo:submit-end (no fetchResponse)
A failed full-page load                A page already on screen
(cold boot offline; tap with           (tunnel mid-scroll; failed frame;
 no cached Turbo snapshot)              failed form submit)
        │                                        │
   NATIVE owns it                           WEB owns it
        │                                        │
 makeCustomErrorView (iOS)              railsfast--offline-banner
 createErrorView    (Android)           (Stimulus, in the app/web layouts)
        │                                        │
 branded, self-healing                  amber "Sin conexión" pill,
 full-screen error view                 "back online" flash,
                                        failed-submit toast (input preserved)

Form-submission failures must live web-side: on Android, form failures (both 4xx/5xx and network drops) do not fire onVisitErrorReceived, and the maintainers' guidance is explicitly to detect them with Turbo's form events on the web side (hotwire-native-android#99). That happens to be the same place the handling can be shared across iOS, Android, mobile web, and desktop — so it's the right home twice over.

Native error views

Both shells expose an official, stable hook to swap the error view while keeping the framework's presentation, retry plumbing, and error taxonomy:

  • iOSHotwire.config.makeCustomErrorView: (HotwireNativeError, ErrorPresenter.Handler?) -> any ErrorPresentableView, installed once at startup (HotwireConfig.swift:92-94, added for hotwire-native-ios#100). Because ErrorPresenter is an extension on every UIViewController, one registration covers tab roots, pushed screens, modals, the auth shell, and the live-card sheet — with zero per-surface plumbing.
  • AndroidHotwireWebFragmentCallback.createErrorView(error: VisitError): View (HotwireWebFragmentCallback.kt:23), overridden on the base WebFragment (inherited by the live-ride card fragment) and the bottom-sheet fragment, so every web surface gets the same screen.

The returned view is inserted into the framework's error container — on Android a fillViewport ScrollView inside the SwipeRefreshLayout, so the framework's built-in error pull-to-refresh keeps working underneath your explicit button.

The two variants

The framework already classifies the failure for you; don't re-derive it from raw URL/WebView error codes. RailsFast maps that taxonomy onto exactly two visual variants:

Variant Classified from Tone Auto-retry?
Offline iOS: WebError.isOffline / .isConnectionError / .isTimeout. Android: the WebError family minus a reachable-but-misbehaving set (auth schemes, redirect loops, bad URLs, blocked resources) — plus the sub-100 HttpError sentinel when internet isn't validated (see the warning below) "No signal here. The instant it's back, we resume where you were." Yes — self-heals on connectivity
Server .http status codes ≥ 100, SSL errors, Turbo LoadError (missing/broken deploy), and the reachable-but-misbehaving web errors "It's us, not you. Try again in a moment." No — manual retry only

Two deliberate calls in that table:

  • Timeouts classify as offline. In a tunnel the radio commonly half-dies — associated, but zero throughput — which surfaces as a timeout, and the connectivity edge is the correct recovery trigger for it.
  • A hurting server never auto-retries. The network is fine; hammering a struggling backend helps nobody, and a 503 maintenance page must not read as "you're offline" (the lesson of turbo-android#341). 401 never reaches this view at all — the auth shell intercepts it upstream (see the server contract).
WARNING

Android's offline-tap funnel: an HttpError does not prove a server answered. A link tapped on a warm page is a Turbo JavaScript visit, not a cold boot. When its fetch dies offline, hotwire-native-android 1.2.8 reports it via visitRequestFailedWithNonHttpStatusCode → a native probe (which also fails offline) → visitRequestFailedWithStatusCode(statusCode = WebError.Unknown.errorCode /* -1 */)HttpError.from(-1) (Session.kt:379-408) — a connectivity failure laundered into an HTTP-shaped error. Naively mapping HttpError → SERVER shows "it's us, not you" to a user who simply lost signal (caught in CarHey's device pass). No real HTTP status is below 100, so the policy classifies sub-100 codes by live connectivity: not-validated/unknown → Offline (self-heal arms); validated → Server (the same funnel fires when the server is reachable but the JS fetch failed for another reason — auto-retry would be wrong there, and its edge would never fire). iOS needs no twin rule: hotwire-native-ios 1.3.0's JSFetchRecoveryHandler converts genuinely-offline JS failures into URLError-backed WebErrors before your view ever sees them.

The classification is a pure function (OfflineErrorPolicy on both platforms), kept UIKit-/Android-free so it's covered by plain unit tests and the two platforms can be held in behavioral lockstep.

Self-healing auto-retry

The offline variant owns a connectivity monitor for exactly as long as it is on screenNWPathMonitor on iOS, a ConnectivityManager.NetworkCallback on Android. Putting the monitor inside the error view (rather than in a delegate or the tab controller) is what makes every surface that can present an error — tab, modal, sheet, auth shell, live-ride card — self-heal identically with no per-surface wiring.

Retries are strictly event-driven — never a polling loop, so the app can't hammer a server or burn battery. The rules:

  • Validated edge. Fire on a genuine offline→online transition observed while this error is on screen. On Android "online" means NET_CAPABILITY_VALIDATED — the OS has actually probed for real internet, which filters captive portals that NET_CAPABILITY_INTERNET alone would let through (docs).
  • The settling probe. An offline-shaped failure presented on an already-healthy path (timeout on a slow server; the exit-tunnel double-fail, where the first retry lost the DNS-settling race and the replacement view starts with no edge left to observe) gets exactly one delayed (~2.5 s) automatic attempt. Without it, those presentations show the "retrying automatically" caption while structurally never retrying.
  • Single-shot per presentation. Each error-view instance auto-retries at most once (edge, foreground, or probe — whichever comes first). Invoking the framework's retry handler removes the view; a failed retry re-presents a fresh instance that re-arms — so a flapping radio can't spin a loop, and there's no reset logic to get wrong.
  • Settle delay. Wait ~400 ms after an edge before firing. An instant retry on the freshly satisfied path fails surprisingly often (DNS isn't routable through the new route yet).
  • Cooldown, rescheduled — never swallowed. Android adds an app-wide ~15 s floor between automatic retries (manual taps are never throttled) so two stacked surfaces — a sheet over a tab — don't both fire on the same recovery edge. An edge denied only by the cooldown is rescheduled for cooldown-expiry: NetworkCallback has no periodic re-delivery on a stable network, so dropping the edge would permanently starve the losing surface.
  • Foreground trigger too. Radios frequently regain service while the app is backgrounded. iOS also retries on willEnterForeground when the path is already satisfied; Android gets this for free because registerDefaultNetworkCallback delivers the current state shortly after (re)registration.

The offline variant also shows the promise out loud — a small caption, "Retrying automatically when the connection returns…", with a quiet spinner. With the settling probe in place, every offline presentation fires at least one automatic attempt, so the caption is always literally true. The server variant just shows a manual "Retry" plus a tiny muted technical detail line (HTTP 503 / error.description()) — release builds compile out debug logging, so that caption is production's only diagnostic sink for which server failure occurred.

iOS: pin 1.3.0, and why

The iOS side is only correct on hotwire-native-ios 1.3.0 (tagged 2026-07-07), and this is load-bearing:

WARNING

On the 1.3.0-beta pin, the stock "Retry" button — and any retry handler — is a silent no-op after a failed cold boot. The retry closure is session.reload(), and Session.reload() bails when topmostVisitable is nil, which is exactly the state after a cold boot that never committed a page. So a branded button built on the beta would look alive and do nothing.

PR #247 "Always provide a retry handler, fix cold-boot retries" (in 1.3.0) makes the handler always present and cold-boot-safe — it re-visits with reload: true when topmostVisitable == nil. Because the retry handler is what the whole self-healing loop pulls, 1.3.0 is the minimum.

1.3.0 also brings the HotwireNativeError taxonomy (.http / .web / .load) with the WebError.isOffline helpers this feature classifies on — the beta hands you a bare Error — plus JSFetchRecoveryHandler, which already natively re-probes and retries transient mid-session Turbo.js fetch blips, so the branded view only has to own the "really offline" case. The bump is mechanical (visitableDidFailRequest and the error-view closure change from Error to HotwireNativeError; a few route-decision handlers take any Navigating), and Session.requestDidFinish — the foundation of cold-tab deferred routing — is verified untouched.

CAUTION

visitableDidFailRequest(_:error:retryHandler:) has a default protocol implementation. If your SceneDelegate override keeps a stale (beta) signature, it silently compiles and is simply never called — your 401→auth-shell routing quietly stops working. Keep visitableDidFailRequest for shell-level concerns (401), and keep the view branding in makeCustomErrorView; don't merge the two.

Android: stays on 1.2.8, but mind the refresh no-op

Android needs no framework bump — the diff from 1.2.8 to 1.3.0 touches routing/logging/lazy-tabs only; createErrorView and the error flow are byte-identical, and 1.3.0's RouteDecisionHandler interface change would be an unrelated breaking migration. But there is one sharp edge in the retry path:

WARNING

HotwireWebFragmentDelegate.refresh() starts with if (webView.url == null) return (HotwireWebFragmentDelegate.kt:158-159). After some failed cold boots the shared WebView never committed a URL, so refresh() — the framework's own error pull-to-refresh included — is a silent no-op. Your branded retry button must not depend on it: when webView.url == null, fall back to a fresh REPLACE route through the navigator instead.

Mid-session on Android already degrades gracefully in one case worth knowing: a JS-visit failure with a cached Turbo snapshot leaves the stale page on screen by default (onVisitErrorReceivedWithCachedSnapshotAvailable), so the branded error view only takes over when there is genuinely nothing to show.

The web layer: the connectivity pill (railsfast-base)

Everything mid-session is one Stimulus controller and one partial, mounted by the app and web layouts on every in-app screen. It lives in app/javascript/controllers/railsfast/offline_banner_controller.js (the template-owned railsfast-- namespace) so it ports upstream verbatim, and renders app/views/shared/_offline_banner.html.erb.

It listens to four signals and needs all of them:

  • offline / online window events — the fast edges. Offline is debounced (~800 ms) against flapping radios; recovery shows instantly (good news never waits). Offline shows an amber "Sin conexión" pill; recovery flashes a green "De nuevo en línea" and slides away.
  • turbo:fetch-request-error — a failed Visit or a failed eager/lazy frame load (turbo#640, turbo#685). This is the ground truth for "a request actually failed" and it fires the pill immediately — because navigator.onLine lies exactly in the cases users describe as "the app stopped working" (captive portals, dead upstream): onLine === true only means "an interface is up", not "the internet is reachable".
  • turbo:submit-end with success === false and no fetchResponse — a form submission that died at the network layer, before any HTTP response. Turbo performs no navigation for such a submission, so the user's input is untouched — which makes the toast copy, "No se ha podido enviar. Tu texto sigue aquí…", literally true. Server-rendered failures (422, 500) carry a fetchResponse and are the page's own business; the pill stays silent for those.
// app/javascript/controllers/railsfast/offline_banner_controller.js
handleTurboSubmitEnd(event) {
  const { success, fetchResponse } = event.detail
  // Network-level failure ONLY: no fetchResponse ⇒ the request never reached
  // the server. Responses WITH a fetchResponse (422/500) are the page's job.
  if (success || fetchResponse) return
  this.showOffline()
  this.labelTarget.textContent = this.submitFailedMessage()
}

The pill is deliberately not the flash system (flashes auto-dismiss; offline is a state that must persist while it's true). It's fixed to the top edge under the safe-area inset so it never fights the bottom dock or tab bar, and it coexists with any existing realtime resilience (e.g. a Turbo Streams reconnect-refresh) because it is presentation-only — it announces, it doesn't heal.

Platform gotchas

The load-bearing traps, collected. Most are called out in context above; keep them together for review.

WARNING

iOS: the retry handler is only always-on in 1.3.0. The 1.3.0-beta's session.reload() silently no-ops after a failed cold boot (fixed by PR #247). Don't build the self-healing loop on the beta.

WARNING

Android: refresh() no-ops when webView.url == null. After some failed cold boots the WebView never committed a URL, so both the framework's error pull-to-refresh and a naive retry button do nothing. Fall back to a REPLACE route through the navigator.

CAUTION

Android XML comments can't contain a double hyphen. <!-- ... --> with a -- inside (e.g. a note mentioning --color-carhey-yellow) is a hard XML parse error that fails the build. It bites in res/layout/view_offline_error.xml and the drawables. Write it as "the yellow brand token" in comments, not the literal CSS var.

WARNING

Dark mode: keep-light the amber pill. Under the dark-mode SSOT, amber-50 card surfaces go muddy. Put keep-light on the pill's surface so it keeps its authored amber; the amber-900/green-900 text passes contrast on both. This is the same amber gotcha documented in dark mode.

TIP

Use native-safe-t for the pill, never a raw inset. The banner is top-fixed, so it reserves top clearance with the documented native-safe-t utility (its 1rem default base doubles as breathing room on notchless web). Never hand-roll env(safe-area-inset-*) — the safe-area contract test fails CI on re-inlined inset reads.

Customizing it for your app

The pattern is generic; the branding is yours. Three levers, no plumbing changes:

Colors and hero. The native views use your brand accent for the hero ring and the retry button, and a system icon for the variant (wifi.slash / wrench.and.screwdriver on iOS, ic_offline_wifi_off_24 / ic_offline_server_24 on Android). Text uses system dynamic colors so dark mode Just Works. Point the accent at your brand token and drop in your wordmark.

Copy. All strings are Spanish-first house tone in the RailsFast starters; swap them for your language and voice in one place per platform (res/values/strings.xml on Android, the title/subtitle switch in the SwiftUI view on iOS, and the offlineMessage() / submitFailedMessage() methods in the Stimulus controller). Keep the two native screens and the web pill in lockstep so a user who sees both reads one coherent story.

Context-aware reassurance. The pill takes a context value so a page can upgrade its copy. This is where CarHey — the app RailsFast's native layer is proven on — earns the most trust: on in-ride screens it declares data-railsfast--offline-banner-context-value="ride", and the offline copy becomes "Sin conexión — tu viaje se sigue registrando" ("No signal — your trip is still being recorded"). That line is truthful precisely because GPS capture and buffering are native and don't depend on the WebView having connectivity — so the single scariest offline moment (a driver mid-earning in a tunnel) is answered with a promise the app can keep. Add your own contexts the same way; keep the controller free of app-specific ifs (the flavor arrives as a value, not a hardcoded check) so it stays upstream-portable.

<%# A page inside an active ride upgrades the offline copy. The controller
    itself stays generic — the context is data, not a branch in the JS. %>
<%= render "shared/offline_banner", context: "ride" %>

What about cached offline content?

Everything above makes the failure graceful. It does not let a user read already-loaded pages with no connection — that's a separate, much larger problem, and the honest recommendation is wait for upstream, don't hand-roll it.

NOTE

The official direction is service workers in Turbo core: turbo#1427 "Add support for (basic, cached on-visit) offline access using service workers" by Rosa Gutiérrez (37signals), previewed in her Rails World 2025 talk "Bringing Offline Mode to Hotwire". It's the right long-term answer — but as of 2026-07 it is open and stalled (last commit 2026-02). Treat its API names as provisional until it merges.

Three reasons not to build it yourself today:

  1. iOS has no HTTP-layer interception at all. WKWebView provides no equivalent of Android's shouldInterceptRequest for http(s), so there is no clean iOS caching seam. When service workers do arrive, iOS needs WKAppBoundDomains declared in Info.plist (max 10 domains) with limitsNavigationsToAppBoundDomains = true set at web-view creation time (via makeCustomWebView) — and turning that on restricts in-WebView navigation to the listed domains, which breaks any in-app OAuth / hosted checkout / KYC page unless you route it to Safari instead. It's a real architectural commitment, not a flag.

  2. The Android-only experimental seam is a trap. Hotwire Native Android carries an OfflineRequestHandler / offlineRequestHandler config hook, but its own KDoc says "Experimental: API may be REMOVED, not ready for production use" (HotwireConfig.kt:31-33). Adopting it as a product feature buys you permanent iOS-vs-Android asymmetry on an API the framework may delete. RailsFast deliberately does not use it.

  3. Hand-rolled snapshot persistence has already failed upstream. Turbo's disk snapshot cache (Turbo.cache.store = "disk") was merged and then reverted over meta-tag leakage and turbo:load timing bugs. A naive service-worker recipe has the same "not production-ready" caveats from its own authors. This is a solved-by-the-framework problem in progress; racing it earns you a maintenance burden and a future migration.

When turbo#1427 lands, adopting it should be a small PR (declare app-bound domains on iOS, honor the x_user_agent cookie server-side so worker-originated requests are still recognized as native), not a research project. Until then: ship the graceful-failure layer, and don't cache content by hand.

One thing you should verify now, because it's the cheap half of first-boot resilience: ship a bundled path-configuration file alongside your remote URL. Both platforms disk-cache the remote config, but a truly first-launch-offline install needs the bundled fallback to have any routes at all (native.hotwired.dev path configuration).

Verification checklist

Test each row on a real device — the simulator/emulator radio behaves differently.

  1. Cold boot in airplane mode → the branded "Sin conexión" screen (logo, brand hero, Spanish), never "Host Lookup"; the auto-retry caption is visible.
  2. Still offline, tap "Reintentar" → button flips to "Conectando…", the screen re-presents (fresh, re-armed) on continued failure.
  3. Turn connectivity back on with the error on screen → the app loads by itself within ~1–2 s, zero taps.
  4. Online, kill the server → the server variant ("Estamos teniendo un problemilla"), no auto-retry caption; restart the server, tap retry → recovers.
  5. Mid-session, then airplane mode → the amber pill slides in; turn airplane mode off → the green "De nuevo en línea" flashes and hides.
  6. In-progress ride + airplane mode → the pill reads "Sin conexión — tu viaje se sigue registrando"; the native tracker keeps buffering.
  7. Offline form submit (e.g. a chat send) → the failed-submit toast, and the composer keeps every character typed.
  8. Dark mode variants of 1 / 4 / 5 → the pill stays legible (keep-light amber); native screens adapt; any light-pinned screens stay light.
  9. Sheets → open an auth or web sheet offline → the branded screen appears inside the sheet, not the stock one.

References

See also: Native server · Android · iOS · Safe areas & fixed docks · Dark mode · Web forms & the keyboard