When I started building mobile apps, I thought good engineering was mostly about code. Clean architecture. The right design patterns. Choosing the right framework. Making things look technically sound.
I spent a lot of time worried about those things.
After working on production mobile products for a few years — maintaining systems long after launch, dealing with real users, changing requirements, shrinking budgets and growing technical debt — I realized software engineering is much bigger than code.
The biggest shift wasn't learning Kotlin, Swift, Firebase, or Cloudflare. It was moving from thinking like a feature developer to thinking like a technical owner.
None of these lessons came from courses or books. Most came from building, maintaining, and sometimes quietly breaking real products after launch.
1. Ownership Is More Valuable Than Speed
Early on, success felt simple. A task comes in, I implement it, I ship it. Done.
Over time I realized that shipping a feature is usually the easiest part. The harder part starts the moment it reaches production. Who handles the edge cases? Who fixes the bugs that show up three months later? Who understands why a decision was made when everyone who made it has moved on?
That's where ownership starts.
I learned this properly when a trivia app I built crossed 250,000 installs and around 40,000 daily active users. In earlier phases, I had built the progression system using SharedPreferences because the scope was small and the timeline was tight. It worked fine. Then real users arrived at scale — different devices, different Android versions, different OEM behaviors. Some users started losing their progress entirely. The root cause was SharedPreferences behaving inconsistently across manufacturers. The fix wasn't another patch on the same system. It meant replacing the persistence layer entirely with Room and rebuilding the progression logic properly.
The business decision at the time was to prioritize speed over migrating existing user data — they had their reasons, and they were reasonable ones. But I still built a one-time migration attempt that ran on app start, which in most cases recovered progress without any negative impact if it didn't.
The lesson wasn't that SharedPreferences was the wrong tool. The lesson was that every shortcut has a future cost, and eventually someone has to pay it.
On the same app, as it grew, the admin panel situation changed too. In the early days, I built a simple admin tool with a Node.js backend and deployed it as a desktop app using Electron. One admin, infrequent updates, manageable. Then active users grew, new features came frequently, and suddenly multiple admins needed access with no coordination mechanism. I had to step back, learn Docker and Google Cloud Run properly, and redeploy the whole thing as a cloud-hosted system. That wasn't a glamorous technical problem. But it was mine to solve because I had owned the product long enough to know it needed to change.
A developer can be fast without creating much real value. Another developer who moves more carefully can save a product months of future headaches. Engineering is not about shipping the most code. It is about being responsible for the outcome.
2. There Is No Perfect Architecture — Only Trade-offs
I used to believe there was always a correct architecture for every problem. A correct pattern. A correct abstraction. I would spend hours trying to figure out whether a certain layer should exist, whether a ViewModel was necessary, whether the architecture pattern was being followed precisely enough.
I wasn't solving product problems. I was chasing architectural correctness.
The clearest example of this came when I was building the iOS version of a video learning platform. The app had playlist screens, content discovery, authentication, and a short-form vertical video feature. In the early phase, SwiftUI was the right call — the features weren't performance-sensitive, the team was small, and SwiftUI's state tools handled the complexity well. I shipped it that way and it worked.
About eighteen months later, the business requirements changed significantly. The reels feature had to scale from around 60–70 short videos to 2,000+, with proper preloading and TikTok-like engagement. They also introduced an Instagram-style feed that needed to handle 5,000+ posts — images, videos, multi-image carousels — all admin-controlled. LazyVStack was no longer appropriate for this. It doesn't efficiently handle paging, preloading, or fine-grained player management at that scale. I needed more control.
That's when I learned UIKit properly, mid-project. I kept all the simpler screens — authentication, playlists, data display — in SwiftUI, and rebuilt the feed and reels features entirely in UIKit using UICollectionView with compositional layouts, player pooling, and diffing. The two layers communicate where needed. On Android, I replaced the older ViewPager approach with RecyclerView, PagingAdapter, Media3 PreloadManager, ExoPlayer with SimpleCache, and proper diffing — managing memory and playback performance explicitly.
It was a significant learning curve. But the more important lesson wasn't technical.
It was that the original SwiftUI decision wasn't wrong. It was right for the product at that stage. The mistake would have been forcing it to stay in place after the product had outgrown it. Architecture exists to serve the product. The product does not exist to validate the architecture.
3. Understanding the Business Changes Every Technical Decision
This is probably the lesson that changed my thinking the most.
Early in my career I assumed technical decisions were purely technical. In reality, they rarely are. Business goals, team size, timeline, and available resources change what "good" actually means.
When I was building the video learning platform, one of the first real decisions was how to handle video hosting and streaming. In the very early phase, I integrated the YouTube API and hosted videos as unlisted content. It wasn't elegant, but it was reliable, fast to build, and let the business experiment with content before committing to infrastructure. That was the right call at the time.
When the business started caring about its own branding and player experience, I had to find a proper VOD solution. I evaluated Mux and Cloudflare Stream seriously. Mux was excellent — great developer experience, full control over infrastructure, strong analytics. But being the only developer responsible for client apps, backend, deployments, and maintenance, implementing and operating Mux wasn't realistic at that scale. Cloudflare Stream handled encoding, storage, CDN, and HLS delivery on its own. That freed me to focus on the apps and features instead of infrastructure operations. We didn't need Mux. Cloudflare was the right fit for where we were.
The admin tooling decision on the same platform tells the same story. The system needed a way to manage video and image uploads separately from the content metadata, which lived in Firestore. There weren't resources to build a full custom CMS at that stage — and honestly, with around 400–500 total videos and only 50–100 uploads per month, it would have been wasteful. So I built two separate tools: a custom web panel I called the Media Manager that handled Cloudflare uploads and returned metadata, and Rowy — a managed Firebase admin service — where admins pasted that metadata into Firestore tables. Admins had to manually copy-paste between the two tools. I knew that going in.
That trade-off was entirely acceptable. The business had very few admins, very infrequent uploads, and I put enough constraints and roles in Rowy to prevent serious inconsistencies. When content eventually scaled and the workflow became too painful, we replaced the whole setup with a unified custom CMS. But for well over a year, the simpler system did exactly what the business needed it to do.
Context matters more than technology. Copying another product's technical decisions without copying their constraints is usually a mistake.
4. Technical Debt Always Returns
Shortcuts aren't always the wrong call. Sometimes they are the right business decision. The problem starts when temporary solutions quietly become permanent systems — and new features start depending on them.
On the video learning platform, we had a complex Firestore schema: categories, subcategories, topics, creators, levels, videos. At one point, the business needed to introduce a new content type — businesses — that didn't fit naturally into that hierarchy. The proper approach would have meant backend changes and schema restructuring. That would take time the business didn't want to spend. So we handled it on the client side. I built a workaround that associated businesses directly with levels and videos without touching the schema cleanly.
In the short term it worked. Then new features arrived that depended on this one. Then more after that. At some point the system became difficult to reason about — for me, and for anyone else who had to touch it. We had to stop, untangle it properly, and restructure before continuing. What started as a two-day shortcut eventually cost more time than doing it correctly would have.
The IAP implementation had a similar arc. I integrated in-app purchases into several projects over time, and in one of them — a one-time purchase flow — the restore logic wasn't being triggered correctly in edge cases. Some users lost their purchased rewards. This wasn't a hypothetical risk. It was a live problem affecting real paying users.
The fix wasn't technically complicated once I understood the root cause. But I had to understand purchase identity, entitlement management, and restore flows at a deeper level than the payment sheet code. The initial implementation treated purchases as a transaction. The fixed version treated them as a system — with receipt validation, restore triggers, and entitlement checks that ran at the right moments.
Every shortcut comes with a future cost. Sometimes the shortcut is still the right decision. But someone will pay the cost eventually, and it is usually the person who owns the product.
5. Communication Is a Technical Skill
The most underrated engineering skill I've encountered is communication — and I don't mean writing status updates or attending standups. I mean the ability to understand what someone is actually trying to achieve, not just what they asked for.
One of the clearest examples came early in the video learning platform project: the choice between Flutter and native Android and iOS. Flutter was the obvious efficiency argument — one codebase, faster initial development. I looked at it seriously. I built prototype features in Flutter to test it properly.
The problem was videos. The platform had video everywhere — playlists, a reels feature, a feed with mixed media. Flutter's player ecosystem at the time wasn't reliable compared to ExoPlayer on Android or AVPlayer on iOS. I also considered a hybrid approach: Flutter for less performance-sensitive screens, with custom platform channel plugins for feed and reels using native code. On paper it works. In practice, managing ExoPlayer and AVPlayer with preloading, caching, and player pooling through platform channels is genuinely complex to build and painful to maintain long-term. Any future developer would need proficiency in Flutter and native Android and iOS simultaneously.
I recommended native — separate Android and iOS codebases. Not because Flutter is bad. Because for a video-heavy product with long-term ambitions and a single developer, the maintenance cost of the hybrid approach was too high. The client's long-term goals were the deciding factor.
That recommendation only made sense because I had taken time to understand what they were actually building, how they saw it growing, and what they could realistically support. If I had just answered the surface question — "Flutter or native?" — I might have given the wrong answer.
Communication means understanding how stakeholders see their business, how they define success, and what problem they believe they're solving. Only then does the right technical decision start to become obvious.
Final Thoughts
Looking back, the biggest lessons didn't come from learning new frameworks or architecture patterns. They came from maintaining systems long after launch, from things breaking in production, from paying back shortcuts that had seemed reasonable at the time, and from taking responsibility for outcomes I hadn't fully anticipated.
Earlier in my career I asked: what is the best technical solution?
Today I ask: what problem are we actually solving, what constraints are real, and what trade-offs can this product live with?
In real product engineering there is rarely a perfect solution. There are decisions, trade-offs, consequences, and responsibilities.
That shift — from optimizing for technical correctness to optimizing for product outcomes — changed how I build software more than any technology I've learned.