Why do new bugs keep appearing in your iOS app?
Back when I was starting out as a junior iOS developer, I often found myself in a situation where QA would report a bug, I'd fix it, and two more would appear in its place. I kept wondering - was I just that careless, or was the problem somewhere else entirely? The answer came with time.
Architecture is the Foundation
Let's start with the most fundamental thing. Most problems that iOS teams struggle with don't stem from the technical difficulties of the platform itself. They stem from architectural decisions made (or not made) at the very beginning of the project.
I've seen dozens of projects where the architecture "emerged" organically, meaning no one consciously designed it. The result? Massive View Controllers spanning thousands of lines, ViewModels that do everything, data models mixed with business logic, networking, and presentation code. The problem is that in such code, changing one thing triggers a cascade of unforeseen effects. You fix one bug, you create three more. Sound familiar?
My five main causes of bugs:
-
Lack of clear separation of responsibilities
When a single class handles data fetching, transformation, validation, caching, and presentation, testing becomes a nightmare, and every modification is a risk. The Single Responsibility Principle exists not because it sounds nice in books, but because it drastically reduces the scope of potential bugs.
-
Poor state management
In mobile apps, state is everything - view state, user session state, network connection state, cache state. When the same piece of information lives in three different places and is synchronized manually, it's only a matter of time before those places start contradicting each other. I've seen apps where a user was simultaneously logged in and logged out, depending on which screen you opened.
-
Tight coupling with frameworks and external dependencies
When business logic is intertwined with UIKit, CoreData, or a specific networking library, every framework change (and Apple loves changing APIs) becomes open-heart surgery. Testing such code is extremely difficult, which leads most teams to simply give up on unit testing.
-
Lack of error handling strategy
Throughout my career, I've often seen code where errors are simply ignored or inadequately handled: empty catch blocks, ignored Result.failure, optionals force-unwrapped "because there's always something there." And then an edge case no one anticipated comes along, and the app crashes with no information about why.
-
Ignoring the application lifecycle
iOS has specific requirements regarding the lifecycle of applications and view controllers. An app can be suspended, resumed, or killed in the background. A view can be deallocated and recreated. A network connection can disappear mid-operation. Code that doesn't account for these scenarios works great on the developer's device and falls apart for users.
What should you do to avoid bugs and simply make your life easier?
-
Invest time in architecture upfront
It's not about choosing between TCA, VIPER, or Clean Architecture - each of these approaches can work well. It's about consciously thinking through how information will flow in the app, where business logic will live, and how modules will communicate. An hour spent at the whiteboard at the start of a project saves weeks of debugging later.
-
Apply dependency injection consistently
This isn't an academic concept - it's a practical tool that allows you to test components in isolation and swap implementations without rewriting half the app. When ViewModels receive their dependencies from outside rather than creating them internally, you can suddenly write tests that execute in milliseconds and verify specific scenarios.
-
Treat errors as a priority
Every operation that can fail should have an explicitly modeled error path. Swift gives us great tools for this - Result, throws, async/await with error handling. Let's use them instead of pretending errors don't exist.
-
Model States Explicitly
Instead of five scattered booleans, use an enum with associated values. Instead of optional fields that "are always filled after login," create separate types for logged-in and logged-out users. The compiler becomes your ally and catches entire classes of bugs before the code reaches a device.
-
Write tests for business logic
I'm not talking about 100% code coverage - I'm talking about covering with tests those places where decisions are actually made. If you have a function that decides whether a user can make a transaction based on account balance, daily limit, and transaction history, that function deserves tests. And if your architecture doesn't let you write such a test without mocking half the iOS SDK, that's a sign something's wrong with the architecture, not with testing.
-
Take code review seriously - both ways
Code review isn't a formality to check off or a hunt for typos. It's the moment when a second pair of eyes can catch a logic error that you missed after several hours of sitting in the same code. But this only works when the reviewer actually understands the context of the changes and has time to dig in. A quick "LGTM" after thirty seconds helps no one. On the other hand - learn from reviews of your code. If someone keeps catching the same recurring issues, that's a signal to change habits, not a reason for frustration.
-
Use AI as your first line of defense
Tools like GitHub Copilot or Claude can catch potential problems before code even reaches review. An unhandled optional, a missing case in a switch, a race condition when accessing shared state - AI is getting better at recognizing these patterns. Don't treat it as a replacement for your own thinking or for code review, but as an additional safety net. Sometimes a fresh "perspective" - even if it's an algorithm - will catch something that you and your team overlooked out of habit.
-
Document your decisions, not just your code
Code tells you how something works. It doesn't tell you why it's done this way and not another. Six months from now, when you return to the project (or someone new joins the team), no one will remember the context. Why is this module extracted into a separate framework? Why are we using this particular approach to caching?
It doesn't have to be elaborate documentation. A simple markdown file in the repository is enough - a so-called Architecture Decision Record (ADR). A short description of the problem, options considered, the decision made, and its rationale. That's it. This saves hours of discussions like "why is it done this way?" and prevents situations where someone "fixes" something that was a conscious decision, creating new problems.
How to identify problems in an existing app?
Sometimes you know something's wrong, but it's hard to pinpoint exactly where. Fortunately, you don't have to rely on intuition alone - there are tools that will help you diagnose the state of your project.
- Static code analysis
Start with tools like SwiftLint or Periphery. SwiftLint will catch stylistic inconsistencies and potential problems, but it will also show you metrics like function length or cyclomatic complexity. If a function has more than 50 lines or complexity above 10, that's a red flag. Periphery, on the other hand, will find dead code that no one uses but has been haunting the project for three years.
- Test coverage - but use your head
Check your test coverage, but don't obsess over the percentage. 80% coverage where tests only check getters and setters is worthless. 30% coverage that covers actual business logic is better. Ask yourself: would the tests catch a bug if I changed a condition in a critical if statement?
- Xcode Instruments and Memory graph
Memory leaks and retain cycles are silent killers of stability. Regularly run the Memory Graph Debugger and check whether objects are being deallocated as they should. If a ViewModel lives longer than the screen that created it, you have a problem.
- Production Metrics
Firebase Crashlytics, Sentry, or even basic metrics from App Store Connect. A crash-free rate below 99% is a sign that something needs attention. Also track which screens generate the most crashes - they often point to modules with the greatest technical debt.
Summary
Most bugs don't come from iOS being difficult or Swift having pitfalls. They come from decisions we make (or avoid) every day - from taking shortcuts, from copying code instead of abstracting, from postponing refactoring "until later," from skipping tests because "there's no time."
Paradoxically, doing things "properly" rarely takes more time. It takes more time at the beginning, but saves it many times over later. A project with clear architecture and good test coverage allows you to make changes confidently and quickly. A project without these things becomes progressively slower over time, because every change requires more and more caution and manual testing.
Of course, bugs will happen. But there's a huge difference between an occasional bug in a complex edge case and systematically generating problems through architecture that was doomed from the start.
The worst bugs aren't the ones that crash the app. The worst are those that make users think: this app is just bad - and they never come back...

Rafał Dubiel
Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.
Udostępnij artykuł: