Build it once for the web and wrap it
Go Native Everywhere
We wrapped the web in native shells because three native codebases were too expensive to staff. Agents made that cost collapse, so I build real native apps now.
For fifteen years the web was the answer to every platform question.
Need a desktop app? Electron. Need a mobile app? React Native, or Capacitor, or Cordova if you were brave. Can't afford any of that? Ship a PWA and tell people to "Add to Home Screen," which nobody has ever done on purpose.
Rails had its own version, and I was fully in that camp. Turbo Native, now Hotwire Native. One codebase, HTML over the wire, a thin native shell, let the server do the work. I pitched it to people as the smart move. It was the smart move.
But every one of those choices was a trade. We accepted worse performance, more memory, worse battery, apps that felt slightly off on every platform, and shallow integration with the operating system. In exchange, we didn't have to learn and staff Swift, Kotlin, and whatever desktop toolkit Windows is on this year.
The web was the lowest common denominator we could afford.
The price tag was people
Nobody chose Electron because it made a better Mac app. They chose it because the team already knew JavaScript, and hiring a Swift developer, a Kotlin developer, and keeping three codebases in sync was a budget line nobody could justify for a companion app. Three codebases meant three skill sets, three sets of bugs, three release cycles. The web shell collapsed that into one.
That cost is gone. Claude writes SwiftUI as comfortably as it writes JavaScript. It writes Kotlin and Jetpack Compose. It knows AppKit, the Keychain APIs, App Intents, WidgetKit, the share sheet. The expensive part of native was never the compiler. It was the humans, and the humans aren't writing the code anymore.
(I made the general version of this argument in Language Strengths Over Language Familiarity. This is the platform-shaped version, and I think it's the one users will actually feel.)
The same menu bar app, twice
I wanted a tiny tool for this site. A menu bar icon that shows how many drafts I have, lets me click one to open it in the editor, and gives me a global shortcut to capture a post idea without opening a browser. It talks to the same Rails API everything else uses.
Old me would have built it in Electron. I know JavaScript, there's a menubar package for it, done by Sunday. It would have worked. It also would have been a copy of Chromium sitting in my menu bar to display a number.
So I built it the way I'd build it now, and had Claude sketch the Electron version too so I could compare honestly. Rough numbers, from my machine, for this one small app:
| Electron | SwiftUI | |
|---|---|---|
| App size | roughly 150 MB | roughly 3 MB |
| Idle memory | roughly 150 to 250 MB across several processes | roughly 25 MB |
| Cold start | a second or two | effectively instant |
| API token storage | a native module or a plugin, plus hoping it builds | Keychain, directly |
| Shortcuts, widgets, notifications | wrappers, partial, or DIY | App Intents, WidgetKit, UserNotifications |
| Feels like a Mac app | close, but you can tell | because it is one |
| Build it before agents | a weekend, in a language I knew | weeks of learning Swift, so I never would have |
| Build it with agents | an afternoon | an afternoon |
That last row is the whole post. When both columns cost an afternoon, the rest of the table is all that's left to decide on, and every row favors the user.
The core of the SwiftUI version is almost embarrassingly small:
@main
struct DraftsBar: App {
@State private var store = DraftStore()
var body: some Scene {
MenuBarExtra("\(store.drafts.count)", systemImage: "doc.text") {
ForEach(store.drafts) { draft in
Button(draft.title) { NSWorkspace.shared.open(draft.editURL) }
}
Divider()
Button("Refresh") { Task { await store.refresh() } }
}
}
}The token lives in the Keychain. The capture shortcut is an App Intent, so it shows up in Shortcuts and Spotlight for free. None of that required me to become a Mac developer. I described what I wanted, read the diff at the level of "what does this touch," and ran it.
The same logic goes all the way down. A real iOS app instead of a web view in a shell. A real Android app in Kotlin instead of a JavaScript bridge pretending to be one. Widgets. Offline that actually works. Share sheet targets. All the stuff that makes an app feel like it belongs on the device, which is exactly the stuff we always cut first because it needed a specialist.
Tools carry values
Years ago I wrote an essay about why we taught Ruby first, and the part I still believe is Dijkstra: "The tools we use have a profound (and devious!) influence on our thinking habits." Every tool has values baked in. Ruby valued the programmer over the machine, and I loved it for that.
Electron has values too. It values the developer over the user. That's not an insult, it's the design. It exists so a team that knows the web can ship to the desktop without learning anything new, and the user pays for that in RAM, battery, and a scrollbar that doesn't look quite right.
Reasonable trade when developer time was the scarcest thing in the room. It isn't anymore. For the first time we get to pick the user without paying for it in headcount. So pick the user.
What the web is still better at
I'm not giving up the web.
Distribution is instant. A URL works everywhere, today, with no install and no App Store review holding a bug fix hostage for two days. If you need to reach people who will never install anything, the web wins.
And this site is still a Rails app. I'm not rewriting avi.nyc in SwiftUI, and I'm not building native versions of things that are really just pages. Hotwire Native still makes sense for a content-heavy app where most screens are documents and you want one deploy to update everything.
The server stays the source of truth, too. The menu bar app is a client. It owns no data and no business rules, and if it disappeared tomorrow nothing would be lost. Rails in the middle, thin clients at the edges, each one actually native to where it runs.
I used to need a reason to go native. Now I need a reason not to.
This is part of a series on everything I'm changing my mind about. Start at Rethink Everything. Next up: Microservices in a Monorepo.