More than seven years ago I and my friends released Prbvlk — the iOS app that shows public transport departures in real time for Samara, our million-people home city. We released a major update in 2018 before the World Cup effectively making Stops the go-to app for our numerous city visitors, but since then we haven’t basically touched it at all. The app still has about 40K monthly active users on iOS and had around 65K on its best days even though Yandex was our competitor. But the locals still preferred Stops as it has always been more accurate, and was just done with a lot of nice touches.
Over the years, we’ve heard a lot of feedback from our amazing users, and I decided to finally update it by adding some long-awaited stuff — the new version is coming later this month. When I opened the repo, I was surprised that the app still builds on the latest Xcode, in addition to still working flawlessly after several major iOS and iPhone releases in a row, and the crash-free rate consistently being at >99.9% over the years. I have always taken it for granted for this app, but then I realized this is something I’ve never seen in my other apps at work.
So, I want to reflect a bit on what can make a mobile app this stable and put this into a list of recommendations that ultimately can make an app low-cost in terms of maintenance. I hope it can be useful for my future self when I build another app and for other software engineers too.
This is not a surprise, but the simpler the app, the fewer errors it should have, the lighter its maintenance cost is, and the lower effort needed to support it. Any feature or any extra layer of complexity usually adds more cost than you would originally think. It’s not just the amount of code that you add — it’s also more testing, more refactoring in the future, and more knowledge transfer if you add more people to the team. So, just don’t include features that are not necessary. Ask yourself if your users or yourself will truly benefit from it and if this is greater than the cost of supporting it.
Example: iPad version of the app
We inherited the bundle id from the local transport app that had been developed a long time ago but still had several thousand active users, and the previous maintainer offered this inheritance himself. We thought it would help us get more users quicker on the new shiny version. But it came with the iPad app flag in its App Store metadata which forced us to support a separate UI for tablets.
It definitely made it harder to iterate. The real question was: did we really need to support tablets in the app that is mainly used on the go? And even if some people did this, would the automatic compatibility mode be enough to cover it? If I had used a fresh bundle id, I would have never enabled the iPad capability — maybe only after several versions once we would finish all the core things and know tablets would unlock more opportunities.
Also, you may notice that even some iOS apps from big tech companies don’t support the native iPad UI. The reason is these apps are very complex, and having to support another screen size would slow them down dramatically.
2a. As few external dependencies as possible
This is similar to the previous tip about simplicity, but I want to point this out separately. Pulling libraries that do some tasks does come at a cost. For every library you are about to use, ask yourself if you can do the same on your own with a reasonable amount of effort. Some examples of popular pods to pull in iOS development that I considered back then:
- Image caching — wasn’t available out of the box, so we had to include a third-party library.
- Auto Layout syntax helpers — everything was provided by iOS SDK, so we didn’t need to pull another dependency here.
- Crashlytics and analytics — we needed it to know about the crashes (App Store still didn’t give crash logs and stats) and user behavior to improve the app.
- UI components like pull-to-refresh or bottom sheets — I didn’t pull them as all the UI could be done by myself.
- Networking — it was so tempting to use the amazing AFNetworking library, and I pulled it in because I was using it for my work project then, however, it all could be done with URLSession, and now I’d probably just use the Apple framework.
The reason to avoid such bloat is you would have to own any dependencies you add, and they can go out of maintenance or will just become incompatible with your other frameworks or toolchain versions. In my case, after I run pod update, most of the stuff just worked, only Firebase had to be updated.
2b. Reliable backend, data, API
This is a continuation of the previous point, however, sometimes you don’t have a choice other than relying on a 3rd party service. In our case, we were fortunate to use the data from a reliable provider of raw data — the city transport operator via their API. But if you use another service API, keep in mind it can go out of business or can become paid which will mean you will have to close your app or change your monetization strategy.
Example: Uber integration
I thought it would be nice to add Uber integration in case there was no transport available (for example, at night), and I even had a separate branch where all the integration was almost complete.
However, I still hesitated to add it because I thought it would still cover a very rare scenario, and people could prefer other taxi apps, not only Uber. Eventually, it turned out to be the right decision as Uber got out of business in Russia and was merged with Yandex, and their APIs and redirects into the Uber app just stopped working. So, to mitigate this I would have to either remove the feature or quickly build another integration that could also become useless at some point.
3. Rigorous Testing
It’s not a coincidence that testing teams are often called Quality Assurance. We made sure we tested manually a lot — at different times of the day (when there is no transport at night), without reliable connection (on the tube), and on various combinations of devices and OSes. We leveraged Testflight to invite as many beta testers as we could and opened various feedback channels to be able to react quickly. Even to this date, we still have a feedback form built into the app, and it served as a great source of wishes and bug reports.
4. People on the team and enthusiasm
This is not a technical tip but still an extremely important one. Eventually, we had a team of four people who enjoyed working on it, and we approached it with the highest quality bar possible. We did it for our city, and we, our families and friends would use the app every day. That’s why we wanted to make it as flawless as possible, and also have fun along the way. And it definitely paid off — we would gather in one of our flats on a Saturday night to fix the issues coming from testing, brainstorm some new ideas, or just polish something together.
iOS-specific bonus: Objective-C helped a lot
As we wrote the app in Objective-C, it basically works as is. If it was written a bit later when Swift came out, maybe around Swift 2 or 3, we would have to do a lot of changes just to support the latest Swift. So, maybe it’s worth using some established technologies and not relying on a framework that tends to change dramatically every several months. For example, a couple of years ago I would probably also avoid SwiftUI, but now it’s probably the right time to use it if you can target iOS 14 and above.
Of course, there is no silver bullet that would magically make your app stable and error-prone. For my app, these tips worked, and applying some of them where possible also helps me on other projects. I hope some developers will find this list useful to build a reliable app.