Pavel Shadrin

about tech, travel, sports and life
25 августа 2023, 3:08

Pribyvalka Update: 2023

I'm thrilled to announce that Pribyvalka got its first major update #3.2.4 since the previous one that happened almost 5 years ago just before the World Cup 2018.

We've heard a lot of interesting and passionate feedback since the original launch in 2015 — at least 3K App Store reviews total and almost 1500 in-app reviews every year. We had addressed some points before, however, this time we've implemented the top 3 user wishes:

  1. Favorites — now the stops that you like are always there on the home page.
  2. Plate number — a quick tap on the transport row on the arrivals list screen now shows the plate number and the model of transport.
  3. Show route on map — now, in addition to the step-by-step route overview, we render the suggested public transport directions on the map.

There were other wishes too, for example, see the transport live on the map — like on Android. This is a good idea but most likely something for the next update ;-)

It's been live for a week already, and two thirds of the users are already on the latest version. The monthly active users number is healthy — close to 35K which is a solid number for the quietest month of the year. The peak was 60K+ in 2018, and I do hope that in September/October we will see the regular 43-47K regular users. The app is as stable as a mountain — as usual — which makes me as a software engineer really happy.

Full what's new notes from App Store:

We slightly got out of our usual cadence to update Pribyvalka once every two years, so we switched to a 5-year one now:
– We've spent all this time thinking where to put the Like button (but still haven't reached an agreement).
– We added the glorious registration plate number and vehicle model label.
– We tried to ask multiple AI chatbots how to open a .sketch file in 2023 to refresh the app icon. We gave up and made it in Figma.
– We wanted to fix stuff under the hood but instead we were surprised that everything still works.
– We built the route visualization on the map. The route calculation algorithm quality is still stuck in 2017, but at least now it looks gorgeous.
– We have been thoroughly gaining experience in the best tech companies of Russia and the world, but the result is still the same – we added new bugs and fixed the old ones.
We do hope that everyone connected to Samara – whether living there, or visiting, or just feeling some nostalgia with the help of Russian VPN – will enjoy swiping the cards and tapping the hearts on their warm summer night.

Download from App Store

As an iOS engineer, for the most time in my career, I have been “transforming JSONs into beautiful UI”. Compared to backend development, handling large amounts of data and doing performance optimizations are not a typical part of our work. Of course, from time to time, performance does matter — especially to make the UI smooth, but the techniques are often different — such as reusing views or offloading expensive work from the main thread. Additionally, if we want the client to be thin, most of the heavy job is delegated to the server, for example, content ranking, search, filtering and so on.

However, sometimes you still have to perform some expensive operations on the client side — for example, because for privacy reasons you don’t want some local data to leave the device. It’s easy to accidentally make those parts of code extremely inefficient — especially if you haven’t built this muscle of quickly spotting potential complexity issues yet. Algorithms and data structures do matter — this is something I only truly realized only several years into my mobile career, and I still see this thing to be often overlooked in the industry. Of course, early optimization is not needed and may even do harm (see premature optimization), but even basic calculations can become performance bottlenecks that severely harm user experience.

There is only one way to solve it — embrace the basics — which means using appropriate algorithms and data structures for the task at hand. One real example that I always recall is a thing I built for one of my projects many years ago. For an invitation flow, I had to implement a contact merge feature where the data would come from three different sources — backend, social account and local iPhone address book. We wanted to combine contacts from these sources into one if they had any overlapping channels (phone numbers or emails). The result would be an array of contacts with all their channels, so there would be no duplicate channels for two different contacts.

At first, my naive approach was just to go one by one and see if in the remainder of the list any contact has overlapping channels with the current one, merge them if yes, repeat. This was needed because, for example, the last channel in the list could have two channels — one that would overlap with the current one, and the second one that could have appeared in the previous contacts which would mean having to go through the list again.

I implemented this, and it worked pretty reliably, here is the pseudocode:

func slowReliableSmartMerge(contacts: [Contact]) -> [Contact]  {
    var mergedContacts = contacts
    var results = [Contact]()
    var merged = true

    while merged {
        merged = false

        while !mergedContacts.isEmpty {
            var commonContact = mergedContacts.first!
            let restContacts = mergedContacts.dropFirst()


            for contact in restContacts {
                if contact.hasNoOverlappingChannels(with: commonContact) {
                } else {
                    merged = true
                    commonContact = Contact.mergedContactFrom(contact: commonContact, otherContact: contact)

        mergedContacts = results

    return mergedContacts

An experienced engineer would quickly spot the issue here, but plese bear with me for a minute. I tested this on my device which had roughly 150 local contacts, 100 friends on social media, and a couple dozen users from the server. It would finish in just a couple of seconds after showing a spinner — “not a huge deal” I thought and moved on to the next feature. Test devices had much fewer contacts, so it worked instantly there. Then a couple of weeks later we started getting some reports from the users that this spinner can take a minute or even longer. Suddenly I realized that the issue was related to complexity, and then I figured that the approach I had taken could actually hit the O(n^2) complexity — similar to the bubble sort.

I quickly discussed that with another engineer on a whiteboard, and we came up with hashmaps to optimize this significantly:

func smartMerge(contacts: [Contact]) -> [Contact] {
    var channelToContact = [String: Contact]()
    var contactToChannels = [Contact: Set<String>]()

    for contact in contacts {
        var mergedContact = contact

        for channel in contact.allChannels {
            if let matchingContact = channelToContact[channel] {
                if mergedContact !== matchingContact {
                    let mergedMatchingContact = Contact.mergedContactFrom(contact: matchingContact, otherContact: mergedContact)
                    contactToChannels[mergedMatchingContact] = (contactToChannels[mergedContact] ?? []).union((contactToChannels[mergedMatchingContact] ?? []))

                    if let channels = contactToChannels[mergedContact] {
                        for c in channels {
                            channelToContact[c] = mergedMatchingContact

                    contactToChannels[mergedContact] = nil

                    mergedContact = mergedMatchingContact
            } else {
                channelToContact[channel] = mergedContact

                if contactToChannels[mergedContact] != nil {
                } else {
                    contactToChannels[mergedContact] = [channel]

    return contactToChannels.keys

The eventual complexity was linear, the spinner would just flicker for a split second, and the tests were luckily green.

Since then, I’ve always been much more alert when it comes to doing some computation on the client side that potentially can have a variable-sized input. This all seems to be very obvious to me now, but back in the day this didn’t look too important to me. I think having a proper understanding of the complexity that comes with various algorithms and data structures can make you a much better software engineer which will lead to better products you build. After all, this is how the big tech companies hire — they value coding skills more than knowledge of certain frameworks.

These days, it’s also important for new folks who switch to software engineering from other areas — they often start their career with simple projects that involve UI work or simply connecting the stuff that’s built on top of well-known frameworks. I’d encourage them to also master the core things like algorithms in order to excel at this job.

Disclaimer: the ideas in this article are based purely on my personal experience working in big tech and smaller companies over the years and multiple conversations I’ve had with other people working in FAANG. Not all big companies out there do the things I describe here, and some smaller companies don’t adopt such practices either.

Smaller tech companies often get inspired by what the big companies like FAANG do — how they manage projects, organize the office space, hire the talent and write code. While it can be useful to leverage some of the best practices, I believe there are things that can actually bring harm if followed blindly. Let me describe several things that I find counterproductive in the small company environment but which are still often used because the grown-ups do it.

1. Interviewing

The traditional interview at a big tech company is a standard mix of coding, system design and behavioral sessions. This is what Cracking the coding interview book is about. Also, this is what leetcode is all about. As a result, lots of engineers try to get trained at solving algorithmic and data structure riddles, and then they never rotate trees nor find the shortest path between two nodes at work.

But does it have to be like that? Big companies do it this way because they value pure problem solvers that can adapt to any framework or tool — in big companies, it’s often an internal framework that’s not used anywhere else. There can also be teams that work on a new language or a new cutting edge technology. It’s believed that the person who can reliably solve algorithmic problems and understand the complexity also can perform well at any kind of programming job that they will face at work.

For smaller companies, what they usually need is work on a product that’s developed using a framework well-known in the industry. For this reason, interviews can totally be done as a test task that is much closer to the actual job. Pair programming sessions can work great too — especially if the goal is to find a teammate that would perfectly fit into a small team.

2. Building own tools and infrastructure

I’ve seen some smaller companies trying to deploy their own git or hg repos or set up fully custom CI pipelines. Big companies often build tools from scratch for the following reasons:

  • Such tools didn’t exist on the market when they already needed them.
  • They need some custom features that the majority of the market doesn’t care about.
  • They don’t want to depend on other services that can go down unexpectedly.

In my opinion, smaller companies shouldn’t spend too much time on re-inventing the solutions that already exist on the market. They can just compare those solutions and choose the one that provides all the necessary features, is reasonably priced and has a good reputation:

  • Do you need to store your code in a versioned system? Probably Github or Bitbucket are your best options.
  • Do you need a continuous integration system? Then maybe use Gitlab or Github’s CI functionality.
  • Maybe you need a mobile CI/CD pipeline? Use Bitrise or other specialized platforms.

Using an existing battle-tested service usually saves a ton of money and allows people to focus on the actual work.

3. Heavy process

Big companies introduce heavy process (such as required system design or product reviews) because of:

  • Their scale — when there is a long chain of people between let’s say a director of product and a product team, they want to make sure the individual team doesn’t go rogue and build something very different from the high-level vision.
  • Fear of shipping something wrong — this fear is even greater than the prospect of shipping a breakthrough, that’s why it often seems to be easier to introduce a process that’s supposed to protect against potential screw ups.
  • Paper trail — some people have to be kept accountable and responsible for the decisions.

All these things are not always needed in smaller companies where all the people usually know each other. In such an environment, it’s often reasonable to trust people over process. For example:

  • Two people on a project don’t need a daily stand-up — they can just informally sync throughout the day (maybe even async).
  • A product designer on a small project doesn’t have to take all the wireframes to a design review every time there is a change — instead, after there is an initial alignment, they can evolve the design independently.


I hope these ideas will help some teams look again at the way they work and try to think if they do actually need it to mimic what the big companies do — or they can get rid of things that make them unnecessarily slower and focus on what really matters more to them.

If this article gets enough traction, I will follow up with the list of three things that are used in big tech but for some reason are often overlooked in smaller companies.

20 февраля 2023, 17:59

What makes an app unbreakable

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.

1. Simplicity

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.

Ctrl + ↓ Ранее