Contact Us

Seamless Loading with Hotwire Native

12 March 2025

Stephen, Director of Engineering
Alan, Senior Software Engineer
Hotwire Native makes it easy to wrap a Rails app. It’s quick, lightweight, and gets you from a Rails mobile web app to a native app with minimal fuss.

Before you hit publish, there are some gaps that you’ll want to consider, for example:
  • Hotwire Native doesn't add support for link schemes such as tel, mailto, or content-disposition
  • There's no out-of-the-box support for tab bars, such as UITabBarController on iOS, or material’s BottomNavigationView
  • Application loading doesn't feel native, because Hotwire Native apps boot fast then shows a loading spinner while the webview loads

In this post, we're going to focus on making the transition from the native launch screen to first paint feel seamless and fluid – so that users never feel like they’re using a browser.

What are we trying to achieve?

Hotwire Native initialises quickly and shows the web view right away. However:

  • On first launch, the app needs to download assets and initialise Turbo/Stimulus
  • OOTB Hotwire shows a spinner while this happens
  • This creates a visible “loading gap” that breaks the illusion of nativeness

The result? The app felt like a web app wrapped in a native shell, rather than a real mobile app.

A native loading screen

Hotwire is designed from the ground up around the idea that you can start with a web app and add native functionality where it’s most needed.

Our broad strategy is to:

  • preload the web view before users see it,
  • detect when the web view is actually ready,
  • then transition seamlessly from the loading screen to the web view.

We're going to refer to iOS and Swift throughout this article, but we found the same techniques just as applicable in our Android and Kotlin build.

Launch screens and loading scenes

iOS applications are required to provide a launch screen implemented as a storyboard that is rendered by the OS while the application is launching. On Android, the developer has control over when the application transitions from launch screen to UI, but on iOS we can’t start loading Hotwire until we've attached a UI view.

We created a SwiftUI view that mimics the launch screen storyboard, and after a bit of time messing around learning constraints (did we mention we’re not iOS developers?) we had a seamless transition from launch screen to loading scene.

Preloading the webview

At this point, we hit a roadblock. Hotwire’s VisitableViewController doesn’t start loading until it’s attached to a scene, and when it is attached it immediately injects its WKWebView on top of the current scene.

After some trial and error, we realised that we can hide the attached WKWebView by making the VisitableViewController's root view fully transparent. We built a SwiftUI Application that renders a stack of background, Hotwire VisitableViewController (as a UIHostingController), and the loading screen we built in the previous step.

We put it all together with a view model that transitions the opacity of the web view once the content is ready. This lets us show the loading view while Hotwire does its thing.
  struct RootView: View {
  @ObservedObject var viewModel

  var body: some View {
    ZStack {
      Color.background

      switch viewModel.state {
        case .preloading:
          LoadingView()
        case .ready:
          EmptyView()
      }

      viewModel.webview.opacity(
        viewModel.state == .ready ? 1 : 0
      )
    }
  }
}
  
A screenshot from XCode should the fully transparent web view loading in the foreground while the loading view renders in the background
XCode's view hierarchy showing the fully transparent web view loading in the foreground while the loading view renders in the background

Detecting when the webview is ready

We need a way to tell when the web view is actually ready to be displayed – i.e. when the assets have been loaded and Stimulus and Turbo have attached.

After looking at native hooks for observing the web view directly, we quickly realised that the simplest option is to put on our web developer hats and use a Stimulus bridge controller:
class PageLoadController extends BridgeComponent {
  static component = "page-load";

  connect() {
    super.connect();
    this.send("connect");
  }
}
When the page loads and the javascript environment is ready, Stimulus will load this controller and send connect across the bridge, at which point the native app can swap out the bridge.

We're assuming that Stimulus will be ready around the same time as all our other assets, but in our experience using HTTP/2 and a CDN for assets, we haven't seen any unexpected un-styled content or similar.

Triggering the transition

To keep our architecture decoupled, we used the Swift bridge component to translate the message from the browser into a NotificationCenter message:

class PageLoadComponent: BridgeComponent {
    override class var name: String { "page-load" }

    override func onReceive(message: Message) {
        NotificationCenter.default.post(
            name: .pageLoadComplete,
            object: nil
        )
    }
}
We registered our view model as a NotificationCenter observer so that we can change our state enum when the page loads.

Putting it all together

  • SwiftUI starts the RootView with a view model in preload state
  • The RootView attaches the Hotwire Navigator which triggers its load behaviour
  • Hotwire attaches its internal webview and starts loading our Rails homepage
  • The homepage loads our JS and assets, including Stimulus
  • Stimulus loads our controller and causes it to send a "connect" event
  • Our native bridge component pushes a "page-load" event to NotificationCenter
  • The NotificationCenter event causes our view model to transition from preload to ready
  • SwiftUI hides the loading screen, revealing the webview

Wrapping up

By preloading the web view, detecting when it’s ready, and using a smooth crossfade transition, we made the Hotwire Native app feel truly native.

We extended this approach to add some additional states:
  • an "offseason mode" where we can show a native informational screen for festival goers while still letting testers through to the app
  • native error screens for network errors
  • an extra pre-load step where we check whether the device is already known to us and pre-authorise the user's webview

We found the experience of using Swift to extend the off-the-shelf web functionality was a great demonstration of the promise of Hotwire as a framework. We could deploy native code as a problem solving tool for a specific issue without wholesale adopting a framework or rebuilding our entire web app in a native framework.

It would be great to improved Hotwire support and better document the teething problems we encountered, as they seem pretty generalisable, but in the mean time, we found Jumpstart Pro to be a great reference for teams getting started with Hotwire Native.

Hotwire Native is a great addition to the Rails ecosystem and we're excited to work with more clients to build cross-platform applications in this lean and cost effective way.