03 — Mobile Development
iOS, Android, cross-platform, and the backends behind them
Mobile apps that shipped to real users — and stayed shipped — for more than eleven years.
Native iOS and Android in their own languages, React Native and Flutter where they fit, and the backends, caches and clouds that make a phone more than a thin shell. The eleven-year track is what makes the rest credible.
I have spent more than eleven years building and shipping mobile applications to real users — and that single fact is the one that makes every other claim on this page worth reading.
Anyone can list frameworks. The credential that matters is longevity: apps that real people installed, kept, and used across iOS, Android and Windows, over more than a decade. The eleven-year track is not a line on a résumé — it is the evidence that the decisions below were made under the pressure of software that had to keep working after launch.
I work native first. On iOS that means Swift and Objective-C with SwiftUI, Core Data, Core Animation and SiriKit; on Android it means Kotlin and Java against the Android SDK across many OS versions. That native fluency is exactly what makes the cross-platform work — React Native and Flutter at an advanced level, Xamarin at a working level — a deliberate choice rather than a way to avoid a platform.
And a mobile app is never just the client. Behind it sit backends in Node, Spring Boot, Django, Flask, Rails and Go, exposing REST and GraphQL, backed by SQLite on the device and Redis on the server, deployed to Firebase, AWS and Google Cloud. The page that follows is that whole surface, told the way I actually build it.
years shipping mobile apps to real users, across iOS, Android and Windows
native platforms maintained in their own languages — Swift/Objective-C and Kotlin/Java
cross-platform stacks I run at an advanced level — React Native and Flutter
backend frameworks I pair with mobile clients, from Node to Go
Two native platforms, two cross-platform stacks, one Windows surface.
The first decision on any mobile project is what to build on, and it is rarely the same answer twice. The matrix below is how I reason about it: native iOS and Android each in their own language, two cross-platform stacks I run at an advanced level, and a Windows surface that keeps the shared logic honest. The diagram maps the languages and frameworks onto the platforms rather than pretending one tool covers everything.
Languages and frameworks, mapped to platforms
A matrix, not a monolith — each platform in the language it rewards.
iOS gets Swift and Objective-C; Android gets Kotlin and Java; Windows gets its own client. React Native and Flutter span the stores when the UI is not fighting the platform, and the column on the right shows where each tool actually lands.
Reading the matrix is the work: it is how I decide whether a feature is native, cross-platform, or a thin client over a shared backend, before a line of UI is written.
- iOS — Swift, Objective-C, SwiftUI, Core Data
- Android — Kotlin, Java, Android SDK
- Cross-platform — React Native, Flutter (advanced)
- Windows — native desktop client
What I actually write on each platform.
The platforms are not interchangeable, and the tooling reflects it. Each tab is a platform I have shipped on — the languages, the frameworks, and the judgment about when each one earns its place.
Native iOS in Swift, with Objective-C where it still lives
On iOS I write in Swift and read Objective-C, because real codebases that have shipped for years are rarely pure Swift. I build interfaces in SwiftUI for new screens and keep UIKit where an existing flow depends on it, rather than rewriting working code for the sake of a framework.
Persistence is Core Data when the app owns a real object graph, and motion is Core Animation when a transition needs to feel native rather than approximated. SiriKit handles voice intents where they earn their place — not on every screen, only where a spoken shortcut is genuinely faster than tapping.
- Swift first, Objective-C read and maintained
- SwiftUI for new screens, UIKit kept where it works
- Core Data for the object graph, Core Animation for motion
- SiriKit intents where voice is actually faster
Android in Kotlin and Java, across many OS versions
On Android I work in Kotlin and Java against the Android SDK, and I have shipped against a wide span of OS versions — the practical work is supporting the devices users actually carry, not just the latest release. That means honest minimum-SDK decisions and testing the fragmentation, not assuming it away.
The same patterns carry across: a clear data layer, a lifecycle-aware UI, and explicit handling of the things Android makes you handle — process death, configuration changes, and background limits that tighten with every release.
- Kotlin and Java against the Android SDK
- Shipped across many Android OS versions
- Lifecycle-aware UI, explicit process-death handling
- Minimum-SDK decisions driven by real devices
Windows clients alongside the mobile work
I also build for Windows, which keeps the surface honest: a feature that has to land on iOS, Android and Windows cannot lean on one platform's conveniences. The shared parts get pushed into the backend and the contracts, and the per-platform parts stay thin.
That discipline is the reason the cross-platform stacks below are a choice rather than a default — I have maintained the native and desktop sides, so I know what each abstraction is actually saving me.
- Windows clients shipped alongside mobile
- Shared logic pushed to backend and contracts
- Per-platform code kept thin and explicit
React Native and Flutter at an advanced level, Xamarin working
React Native and Flutter I run at an advanced level. I reach for them when a product needs one team to ship two stores quickly and the UI is not fighting the platform — and I drop to native modules the moment a screen needs Core Animation or a sensor the bridge does not expose cleanly.
Xamarin I have at a working level, which matters in .NET-leaning organizations where the rest of the stack is already C#. The point is not loyalty to a framework; it is matching the stack to the team and the product.
- React Native — advanced, with native modules where needed
- Flutter — advanced, single codebase for two stores
- Xamarin — working level, for .NET-leaning teams
- Drop to native when the bridge gets in the way
Swift the way it reads on a real screen.
A code panel is more honest than a feature list — this is the shape of the iOS work, not a description of it.
On iOS I lean on SwiftUI for new surfaces and Core Data for the object graph behind them. The snippet below is the kind of thing I write daily: a small, typed model, a fetch the view can observe, and a layout that stays declarative. It is deliberately ordinary — the point of native fluency is that the routine code is clean, not clever.
import SwiftUI
import CoreData
struct ContactList: View {
@FetchRequest(
sortDescriptors: [SortDescriptor(\.name)]
) private var contacts: FetchedResults<Contact>
var body: some View {
List(contacts) { contact in
VStack(alignment: .leading) {
Text(contact.name)
.font(.headline)
Text(contact.phone)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.animation(.easeInOut, value: contacts.count)
}
} The iOS framework set
SwiftUI on top, Core Data underneath, SiriKit where voice is faster.
The frameworks compose: SwiftUI renders, Core Data persists, Core Animation handles the motion SwiftUI does not, and SiriKit exposes the one or two intents where a spoken shortcut genuinely beats tapping. Objective-C stays in the picture because long-lived apps still contain it, and reading it is part of maintaining them.
None of this is added for its own sake. Each framework is in the app because a specific problem — persistence, motion, voice — asked for it.
- SwiftUI for new screens, UIKit kept where it works
- Core Data for the object graph
- Core Animation for native-feeling motion
- SiriKit intents only where voice is faster
Kotlin against the Android SDK, fragmentation and all.
Android rewards explicitness. The snippet below is a Kotlin ViewModel exposing UI state as a flow — lifecycle-aware, survives configuration changes, and does its loading off the main thread. Having shipped across many OS versions, I write Android code that assumes process death and tight background limits rather than hoping to avoid them.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class ContactViewModel(
private val repo: ContactRepository
) : ViewModel() {
private val _state =
MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
init {
viewModelScope.launch {
runCatching { repo.loadContacts() }
.onSuccess { _state.value = UiState.Ready(it) }
.onFailure { _state.value = UiState.Error }
}
}
} Many OS versions, real devices
Support the phones users carry, not the one on the keynote.
Android fragmentation is not a problem to complain about — it is the environment. The work is choosing an honest minimum SDK, testing the spread of versions and screen sizes that the actual user base runs, and handling the background and permission rules that tighten with every release.
Java stays in the toolkit alongside Kotlin for the same reason Objective-C does on iOS: shipped code lives a long time, and maintaining it means reading it fluently.
- Kotlin and Java against the Android SDK
- Honest minimum-SDK choices
- Tested across the versions users run
- Explicit background and permission handling
The shape of an app, drawn from UI to data store.
A mobile app is a stack of clear layers — and the discipline is keeping them clear so each one can be tested and replaced on its own.
Whatever the platform, the architecture I reach for is the same in spirit: a UI layer that only renders state, a domain layer that holds the logic, a data layer that talks to the network and the device store, and a sync boundary in between. The diagram shows the layers and the single direction the dependencies point.
Keeping the arrows pointing one way is what makes the app maintainable a year later — the UI does not reach into the network, and the network does not know about the UI. The same shape holds whether the client is Swift, Kotlin, React Native or Flutter.
UI → domain → data → store
Four layers, dependencies pointing one way.
The UI observes state and emits intents; the domain layer decides; the data layer fetches and persists; SQLite holds the on-device truth and the network carries the rest. Each boundary is a place I can put a test or swap an implementation without the layers above noticing.
It is not architecture for its own sake. It is the structure that let eleven years of apps keep accepting features without collapsing under them.
- UI renders state, never reaches the network
- Domain layer holds the logic and the rules
- Data layer owns network and device persistence
- SQLite as the on-device source of truth
The server side of every mobile app.
A mobile client is only as good as the backend it talks to — so I build that backend too, in whatever framework fits the team.
Behind the apps I have shipped Node/Express, Spring Boot, Django, Flask, Ruby on Rails and Go with Gin and Gorm. The framework is chosen for the team and the latency budget, not by habit: Node where the team is JavaScript-fluent, Spring where it is a JVM shop, Django or Flask for Python-heavy backends, Rails for convention-driven CRUD, and Go where the per-request budget is tight.
Each one exposes a contract — REST or GraphQL — that the mobile client is built against, persists through ORMs like Sequelize and Mongoose, and caches the hot path in Redis. The process below is the same regardless of which framework sits in the middle.
From contract to a synced device
- 01 Define the contract REST or GraphQL schema agreed first — the client and server are built against one contract, not against each other.
- 02 Choose the framework Node/Express, Spring Boot, Django/Flask, Rails or Go (Gin/Gorm) — picked to fit the team and the latency budget.
- 03 Model the data ORMs — Sequelize or Mongoose — over the relational or document store the domain actually needs.
- 04 Cache the hot path Redis in front of the expensive reads, with explicit invalidation rather than hopeful TTLs.
- 05 Sync the device SQLite on the device, a sync protocol over the contract, conflict resolution defined before it is needed.
- 06 Ship the pipeline Signed builds, automated tests, store delivery — the CI that turns a commit into a build a user can install.
func GetContact(c *gin.Context) {
id := c.Param("id")
if cached, err := rdb.Get(ctx, id).Result();
err == nil {
c.Data(200, "application/json",
[]byte(cached))
return
}
var contact Contact
if err := db.First(&contact, id).Error;
err != nil {
c.JSON(404, gin.H{"error": "not found"})
return
}
c.JSON(200, contact)
} A Go handler, the way it ships
REST and GraphQL, served fast and typed.
The snippet is a Gin handler reading through Gorm with a Redis cache in front of it — the small, repeatable shape of an endpoint a mobile client can rely on: a typed response, a predictable error, and a cache that takes load off the database on the hot path.
Whether the framework is Go, Node or Spring, the contract the phone sees is the same. That is the point of agreeing it first.
- Go with Gin and Gorm on the tight-budget paths
- Redis caching the expensive reads
- Typed responses, predictable error shapes
- Same contract regardless of framework
The full mobile stack — client to cloud
- iOS languages
- Swift · Objective-C
- iOS frameworks
- SwiftUI · UIKit · Core Data · Core Animation · SiriKit
- Android languages
- Kotlin · Java
- Android target
- Android SDK, many OS versions
- Cross-platform
- React Native · Flutter (advanced) · Xamarin (working)
- Desktop
- Windows clients
- Backends
- Node/Express · Spring Boot · Django/Flask · Rails · Go (Gin/Gorm)
- APIs
- REST · GraphQL
- On-device / data
- SQLite · Redis · Sequelize · Mongoose
- Mobile cloud
- Firebase · AWS · Google Cloud
- Frontend craft
- HTML5 · CSS3 · SASS/SCSS · JS ES6+ · TypeScript
When the network fails — and on a phone, it will.
A phone moves through tunnels, elevators and dead zones. An app that assumes connectivity is an app that hangs on a spinner in front of a real user.
I build mobile apps offline-first: the device writes to SQLite immediately and queues the change for sync, so the user never waits on the network to see their own action take effect. When connectivity returns, the queue drains over the REST or GraphQL contract, and conflicts are resolved by a policy decided per entity before it was ever needed.
The diagram traces that loop — local write, queue, sync, reconcile — and the failure mode is the part that matters: the app degrades to read-only rather than silently losing a write. That is the difference between an app users trust and one they abandon.
Local write → queue → sync → reconcile
The device is the source of truth until the server agrees.
Every write lands in SQLite first and is mirrored into a sync queue. A background sync drains the queue against the server, the server reconciles using Redis-backed reads and the ORM-mapped store, and the resolved state flows back to the device. The user sees instant local feedback and eventual consistency, not a spinner.
Designing the conflict policy up front — last-write-wins, merge, or manual — is what keeps the loop honest when two devices edit the same record.
- Local write to SQLite before any network call
- Change queued, drained when connectivity returns
- Conflict policy defined per entity in advance
- Degrade to read-only, never lose a write
Offline-first — the contract with the network
- On-device store
- SQLite — the source of truth while offline
- Write path
- Local write first, queued for sync
- Sync transport
- REST or GraphQL over the agreed contract
- Conflict policy
- Defined per entity before it is needed
- Server cache
- Redis in front of the expensive reads
- Server store
- Relational or document, via Sequelize or Mongoose
- Failure mode
- Degrade to read-only, never silently lose a write
The user should never wait on the network to see their own action. Write locally, sync in the background, and never lose a write — that is the whole offline-first discipline.
Firebase, AWS and Google Cloud — chosen by fit.
The cloud is not one decision. A small team needs a backend yesterday; a scaling product needs control over its compute. Each tab is a platform I deploy mobile backends to, and the judgment about when each one is the right answer.
Firebase where a small team needs a backend yesterday
Firebase earns its place when a mobile product needs authentication, a synced datastore and push without standing up infrastructure first. I use Auth for sign-in, Firestore for the structured, queryable data, and the Realtime Database where the access pattern is genuinely a live tree rather than documents.
The discipline is knowing where Firebase stops being the right answer — security rules that have to encode real authorization, and read costs that grow with a naive data model. I design the documents around the queries, not the other way around.
- Auth for sign-in, Firestore for structured data
- Realtime Database where a live tree fits the access pattern
- Security rules treated as real authorization
- Documents designed around the queries
AWS for the backends that outgrow a BaaS
When a product needs control over its compute, I run it on AWS — Lambda for event-driven and spiky workloads, EC2 where a long-lived process or a specific runtime is required, and S3 for the media and the static payloads a mobile client downloads.
Mobile changes the constraints: cold starts are felt on a phone, upload retries have to survive a flaky cellular link, and the device cannot hold a connection open the way a server can. The architecture accounts for the network the device is actually on.
- Lambda for event-driven and spiky workloads
- EC2 for long-lived processes and specific runtimes
- S3 for media and downloadable payloads
- Designed for flaky cellular, not a datacenter LAN
Google Cloud where the platform fits the workload
On Google Cloud I use App Engine for managed services that scale without me babysitting instances, and Compute Engine where a workload needs a full VM. It pairs naturally with Firebase when part of a product is a managed datastore and part is custom compute.
The choice between clouds is rarely ideological. It is which managed services remove the most operational work for this team while keeping the latency the mobile client sees inside budget.
- App Engine for managed, auto-scaling services
- Compute Engine where a full VM is required
- Pairs with Firebase for mixed managed/custom backends
- Chosen by operational fit, not loyalty
Where the backend actually runs
Managed where it saves work, controlled where it has to be.
Firebase Auth and Firestore stand up a synced backend without infrastructure; Lambda and EC2 give serverless and long-lived compute on AWS; App Engine and Compute Engine do the equivalent on Google Cloud; S3 holds the media a phone downloads. The diagram maps the client onto those options.
The mobile constraint runs through all of it: cold starts are felt on a phone, and uploads have to survive a cellular link. The cloud is chosen so the latency the user sees stays inside budget.
- Firebase — Auth, Firestore, Realtime Database
- AWS — Lambda, EC2, S3
- Google Cloud — App Engine, Compute Engine
- Chosen by operational fit, latency in budget
React Native and Flutter — a choice, not a default.
Cross-platform stacks let one team ship both stores from one codebase, and I run React Native and Flutter at an advanced level. But the reason I trust them is that I have shipped native first: I know exactly what the bridge is saving me and where it gets in the way. When a screen needs Core Animation, a sensor the bridge does not expose cleanly, or a platform behavior the abstraction papers over, I drop to a native module without hesitation.
Xamarin sits at a working level in the toolkit, which is the right answer in .NET-leaning organizations where the rest of the stack is already C#. The decision among them is never about loyalty — it is about matching the stack to the team and the product, with native always available underneath.
One codebase, two stores, native escape hatch
Shared business logic up top, native modules where the bridge stops.
In a cross-platform app the business logic and most of the UI live in one shared codebase — React Native or Flutter — that renders to both iOS and Android. The parts the abstraction cannot serve drop through to native modules in Swift and Kotlin, so the app never trades capability for portability.
That escape hatch is the whole reason native fluency matters even on a cross-platform project. The shared layer covers the common ground; the native modules cover the edges.
- Shared logic and UI in one codebase
- Renders to both iOS and Android
- Native modules in Swift / Kotlin at the edges
- Capability never traded for portability
The pipeline that turns a commit into an install.
Mobile CI is harder than web CI — there are two toolchains, code-signing, and two store review processes between the commit and the user.
A mobile release is not a deploy; it is a build, a signature, and a submission. The pipeline I run lints and type-checks first, then builds the iOS archive with Xcode and signs it, builds the signed Android APK or AAB with Gradle, and ships versioned artifacts to TestFlight and the internal track before the store. The rule is simple: no green pipeline, no release.
The diagram below shows that fan-out — one commit, two toolchains, two stores — because the cost of getting code-signing or a version bump wrong is a rejected submission and a day lost, not a quick re-deploy.
Commit → checks → two builds → two stores
One commit fans out into two signed builds.
The pipeline runs the static checks once, then splits: an Xcode path that builds, signs and archives for iOS, and a Gradle path that assembles and signs for Android. Each produces a versioned artifact that goes to its store's test track before release.
Treating the signed build as the only thing that ships — never a local archive from someone's machine — is what keeps releases reproducible across eleven years of apps.
- Lint, type-check and unit tests as the gate
- Xcode signs and archives for iOS
- Gradle assembles signed APK/AAB for Android
- Versioned artifacts to test tracks, then store
Mobile CI — stage by stage
- Trigger
- Commit to a release branch
- Static checks
- Lint, type-check (TypeScript), unit tests
- iOS build
- Xcode build, code-signing, archive
- Android build
- Gradle assemble, signed APK/AAB
- Artifact
- Versioned build per platform
- Delivery
- TestFlight / internal track, then store
- Gate
- No green pipeline, no release
The web surfaces that sit beside the app.
A mobile product is rarely only the app. There are onboarding pages, marketing surfaces, embedded web views and shared logic that all want the same care as the native code. I build those in clean HTML5 and CSS3, organize the styles with SASS/SCSS so the design system stays a system, and write the logic in modern JavaScript — ES6+ and TypeScript wherever the project tolerates a type system.
TypeScript in particular pays for itself on mobile: a typed contract catches the mismatch between client and server before a user ever sees it, and the same types can flow from the GraphQL schema down into the React Native client.
HTML5 & CSS3
The web surfaces that sit beside a mobile app — onboarding pages, marketing, embedded web views — built in clean HTML5 and CSS3 rather than dumped into a framework by reflex.
SASS/SCSS
Stylesheets organized with SASS/SCSS so the design system stays a system: variables, partials and mixins instead of copied declarations.
JavaScript ES6+
Modern JavaScript for the shared logic and the web layer — modules, async/await, and the language features that make a codebase readable years later.
TypeScript
TypeScript wherever the project tolerates it, because a typed contract catches the mismatch between client and server before a user ever sees it.
REST APIs
REST for the straightforward resource-shaped endpoints, with versioning and predictable error shapes the mobile client can rely on.
GraphQL APIs
GraphQL where a mobile screen needs exactly its data in one round trip — the network round trip a phone pays for is the one worth saving.
Eleven years, one platform decision at a time.
Foundation, native, cross-platform, backend, cloud — the same engineer, following the work as products grew past their first thousand users.
Read in sequence, the mobile work is one continuous track rather than a list of frameworks. It starts with eleven years of shipping to real users, deepens into native iOS and Android, extends across React Native and Flutter where they fit, reaches back into the backends that make a client more than a shell, and arrives at the clouds that scale it.
What carries through all of it is the same refusal that runs through the rest of my work: the app and the system behind it are one problem, not two handed between two people.
- Foundation Eleven years of shipping to real users The track that anchors everything else: more than eleven years building and shipping mobile apps that real people installed and used, across iOS, Android and Windows.
- Native iOS and Android in their own languages Swift and Objective-C on iOS with SwiftUI, Core Data, Core Animation and SiriKit; Kotlin and Java on Android across many OS versions. The native fluency that makes the cross-platform choices informed.
- Cross-platform React Native and Flutter at an advanced level One team shipping two stores where it fits, dropping to native modules where it does not — plus Xamarin at a working level for .NET-leaning teams.
- Backend The server side of every app Node/Express, Spring Boot, Django/Flask, Rails and Go (Gin/Gorm), exposing REST and GraphQL, backed by SQLite, Redis and ORMs — the backends that make a mobile client more than a thin shell.
- Cloud Firebase, AWS and Google Cloud Managed datastores, serverless compute and storage chosen by operational fit — the infrastructure that scales an app past its first thousand users.
The principles underneath the platforms.
The platforms and frameworks change with the product; the principles do not. These are the rules I apply whether the app is native Swift, native Kotlin, React Native or Flutter — the part that turned eleven years of mobile work into apps that kept working rather than a portfolio of launches.
Native fluency earns the abstraction
Because I have shipped Swift and Kotlin directly, choosing React Native or Flutter is a measured trade, not a way to avoid learning a platform. I know what the bridge is saving me and what it is hiding.
The contract comes first
Client and server are built against one agreed REST or GraphQL contract, ideally typed end to end. The integration is designed before either side is written, not negotiated after both break.
Assume the network will fail
A phone moves through dead zones. The app writes locally to SQLite first, queues for sync, and degrades to read-only rather than losing a write or hanging on a spinner.
Respect the platform's rules
Background limits, process death, store review, code-signing — these are not obstacles to route around. Working with them is what keeps an app installed instead of uninstalled.
Test the fragmentation
Many Android OS versions and a span of iOS devices means testing what users actually carry, not asserting that the latest release covers everyone.
Eleven years is the credential
Every claim here is backed by apps that shipped and stayed shipped. The longevity is the evidence — not a framework on a slide, but software that survived contact with real users.
Eleven years of apps that shipped and stayed shipped is the credential. Everything else on this page is true because that one thing is.
Open to the right work
If your product is a mobile app and the backend, the cloud and the offline behavior that make it trustworthy, that is the whole problem I want.
If you are holding a problem that doesn't fit inside one field, that is the conversation I want.