Rewriting ONTRACK in Rust, Swift and Kotlin
The backend is moving from Django to Axum in Rust, and the app from React Native to Swift on iOS and Kotlin on Android. Not because those things are fashionable, but because a smaller backend and a phone doing more of the work make ONTRACK cheaper to run as it grows.
25 August 2026 · 8 min read
ONTRACK is being rebuilt.
The backend is moving from Django to Axum in Rust. The app is moving from React Native to native Swift on iOS and Kotlin on Android.
There are three main reasons: speed, simplicity, and cost.
The more I worked on ONTRACK, the more I realised that I was paying — both in hosting costs and dependency complexity — for things that either a much smaller backend could do more efficiently or the phone itself already does extremely well.
That matters because ONTRACK's running costs ultimately decide how much of it I can afford to keep free.
A smaller backend
ONTRACK currently runs on Django in Google Cloud Run.
Django has served the project well, but it has gradually become a fairly large service. It needs enough memory that I keep an instance warm rather than tolerate the cold start, which means paying for it even when nobody is using the app. Its dependency list has also grown to the point where it is difficult to reason about as a whole.
The Rust service is very different.
It uses Axum, consumes a fraction of the memory and can comfortably scale all the way down to zero when nobody is using it.
Its container is built FROM scratch: there is a statically linked binary, a CA
certificate bundle, and essentially nothing else. No interpreter. No package
manager. No operating-system image full of things the application doesn't need.
The direct dependency list fits comfortably on one screen.
Scaling to zero is only useful if starting again is fast. Rust helps there too.
Starting the service means executing a compiled binary. Starting Django means starting the Python interpreter and importing a substantial dependency tree before the application is ready to answer requests.
Those two things — lower memory use and being able to scale to zero — have a direct effect on cost. Cloud Run charges according to the resources a service consumes over time, so eliminating an always-running memory-heavy instance matters.
And that is not just infrastructure trivia.
Every pound ONTRACK doesn't spend keeping idle servers alive is a pound that doesn't need to be recovered through subscriptions. I'd much rather keep things like maps, the training log and club features free, while charging for features that genuinely have a marginal cost every time somebody uses them, such as the coach.
Porting it without breaking anything
The backend is being moved one route at a time.
The rule is simple: the Rust endpoint must behave exactly like the Django endpoint it replaces.
The worklist was built backwards from LiveBackend.swift. Rather than porting
everything Django happens to expose, I went through every endpoint the app
actually calls. If Django serves something that no client uses, it doesn't get
rewritten. It gets retired.
That has made the rewrite much more manageable.
It has also produced some surprisingly obscure compatibility problems.
Django's DatabaseCache, for example, stores values as base64-encoded Python
pickles. To read the same cached data during the migration, the Rust service now
contains a small pickle reader.
One paginator exposes an offset that Python calculates using arbitrary-precision
integers. Ask for a sufficiently ridiculous page number and Python will happily
return a JSON number larger than a 64-bit integer can represent. Rust's usual
JSON handling therefore can't simply parse it as an integer; serde has to
preserve the raw number instead.
Then there was Unicode.
Python's str.lower() implements the Unicode rule for Greek final sigma. Rust's
straightforward per-character lowercase conversion does not behave identically.
That meant a name ending in sigma could theoretically normalise differently
depending on which backend handled the request.
The fix was to use ICU and pin the dependency to an exact version, because a future change to Unicode tables could otherwise change the behaviour of the API.
None of that makes ONTRACK faster.
It is simply the cost of making the migration invisible to the app.
That's important. I want to be able to switch an endpoint from Django to Rust — or switch it back — without anybody using ONTRACK being able to tell.
So parity is defined by results, not implementation. Rust is allowed to reach the answer differently. It just isn't allowed to produce a different answer.
That distinction has already been useful.
One startup permission check was originally written as a correlated NOT EXISTS
query. PostgreSQL eventually chose a plan that repeatedly evaluated an expensive
four-catalogue system view, hit the database role's statement timeout and
prevented the service from starting.
On Cloud Run, a container that cannot start means a revision that never receives traffic.
Rewriting the query using a WITH expression gave the same result in
milliseconds.
Same behaviour. Better implementation.
Letting the phone do more
The native app rewrite is slightly different.
Here, the biggest attraction isn't raw CPU performance. It is removing layers of software that exist mainly to reach features the operating system already provides.
The React Native app has accumulated a long list of npm dependencies. More
tellingly, every patch in its patches/ directory exists to fix or alter a
native bridge: HealthKit, maps, the bottom tab bar and analytics.
The Swift app currently has one external dependency: Google's sign-in SDK.
Almost everything else comes directly from iOS.
Maps are probably the best example.
The React Native version of ONTRACK's 3D route screen uses Mapbox. It combines a
style URL, a FillExtrusionLayer over the composite source and a
RasterDemSource for terrain relief.
I originally wrote the screen down as one of the difficult parts of the migration because MapKit doesn't expose direct equivalents for those Mapbox concepts.
It turned out that it didn't need to.
MapStyle.standard(elevation: .realistic) already gives the app terrain and
extruded buildings. What had been a fairly involved Mapbox implementation became
roughly a screen of Swift with no additional dependency.
The metro map produced a similar result.
The React Native implementation contained four helper builders for Mapbox paint
expressions and around 120 lines of workarounds for rnmapbox 10.3.0. In the
native version, I partition the data first and hand each partition its colour.
HealthKit went from a wrapper package plus a patch to import HealthKit and
about 40 lines of code.
The small animation at the end of onboarding used Lottie. It is now about 20 lines of SwiftUI shapes.
There is a recurring pattern here: a lot of the code wasn't actually implementing ONTRACK. It was implementing the gap between JavaScript and the operating system.
Native development removes much of that gap.
Maps also change the economics
There is another reason the mapping change matters.
Mapbox charges per map load.
Every time somebody opens an activity in ONTRACK, the app draws a map. That means the mapping bill increases roughly alongside usage.
MapKit on iOS and Google's Maps SDK on Android do not charge ONTRACK simply for displaying those maps.
For a social running app where maps appear everywhere, that difference becomes increasingly important as the number of users grows.
Again, this comes back to what I want the economics of ONTRACK to look like.
Growing the app shouldn't automatically mean putting more of it behind a paywall just to cover infrastructure bills.
Less code — with an important caveat
Some of the reductions in code are dramatic.
The native onboarding flow, for example, ended up at well under half the size of the React Native version.
But I don't think that means Swift is somehow twice as expressive as React Native.
Much of the old code was repetition that had accumulated over time. Every onboarding screen separately recreated things like safe-area padding, maximum content width and its entrance animation.
The new version has a single scaffold that handles all of that.
So part of the improvement comes from native APIs, but part of it is simply the benefit of designing something for the second time.
A rewrite is a very effective way of discovering which parts of your old architecture were actually necessary.
The cost of going native
There is, of course, an obvious downside.
ONTRACK will now have two app codebases instead of one.
That is real additional work.
The way I'm trying to control it is by treating the iOS app as the reference implementation and porting it to Android screen by screen, rather than independently designing two applications.
Anything that is pure maths or domain logic goes into a plain Kotlin module that
isn't allowed to import anything from android.*. The intention is to keep the
platform-specific layers concerned with the platform, not with reinventing
ONTRACK's underlying logic twice.
There are also some pieces of infrastructure that native apps force me to change.
Push notifications are one example.
The existing backend sends notifications through Expo's push service, which expects an Expo push token. A native iOS application receives an APNs device token instead.
So before the native app can fully replace the Expo version, ONTRACK's backend needs to be able to talk directly to Apple's Push Notification service.
Rewrites get harder near the end
The backend has its own version of this problem.
Read endpoints are generally straightforward to move. Write endpoints require more care because the expensive part isn't typing out the Rust implementation; it's convincing myself that all of the edge cases and permissions behave exactly as they did before.
Then there is the long tail.
This is where rewrites tend to become unexpectedly large.
The Django admin is a good example. I had mentally filed it away as a relatively small part of the application. When I actually went through it, I discovered that almost every important admin screen had been customised in some way.
Rather than blindly recreating the entire thing, I'm going to look at the access logs and find out which parts are actually used.
Those get rebuilt.
The rest can disappear.
That is one of the broader principles behind the whole migration: don't assume that everything accumulated over the life of a project deserves to survive into the next version.
Measure it first.
What I can say so far
I haven't yet put the Django and Rust services side by side and produced proper request-latency benchmarks, so I'm not going to claim that a particular endpoint is ten times or fifty times faster.
That would be guessing.
What is already obvious is the broader shape of the system.
The Rust backend uses less memory. It starts quickly enough to scale to zero. It has a much smaller dependency surface. The native apps can use HealthKit, maps, animation and other system capabilities directly instead of reaching them through layers of third-party bridges.
And several costs that previously grew directly with usage can disappear entirely.
Those things matter more to me than winning a benchmark.
The goal is not to rewrite ONTRACK in Rust, Swift and Kotlin because those technologies are fashionable, or because native code is inherently better.
The goal is to make ONTRACK simpler and cheaper to operate as it grows.
Because ultimately, the cheaper the app is to run, the more of it I can afford to let people use for free.