
Most mobile app post-mortems tell the same story. The architecture looked fine at the start. The first few sprints shipped clean. Then somewhere around sprint eight or ten, velocity collapsed. Features that should have taken a day started taking a week. The codebase became something the team worked around rather than inside.
The decisions that caused this were made before a single user touched the app.
This is a breakdown of where those decisions typically go wrong specifically for iOS and Android apps built for Chicago's healthcare, logistics, and fintech markets, where the integration surface is wide and the tolerance for production failures is low.
1. Choosing a Framework Without Auditing the Integration Surface First
The React Native vs Flutter vs native debate gets framed as a performance question. It's actually an integration question.
Before a framework decision gets made, the right exercise is to map every third-party system the app will talk to: EHR systems, payment processors, mapping SDKs, push notification services, background sync requirements, Bluetooth or NFC if the app has hardware interaction. Then ask which of those integrations have well-maintained native modules for the chosen framework and which ones will require writing a bridge.
React Native handles most standard integrations cleanly. Where it breaks down is deep platform APIs anything that requires persistent background execution on iOS, fine-grained Bluetooth control, or tight integration with Apple's health data stack. If those are core features, native Swift removes an entire layer of indirection.
Flutter has a different tradeoff. Its rendering consistency is genuinely excellent you get pixel-perfect parity across iOS and Android because Flutter draws its own UI rather than delegating to platform components. But the plugin ecosystem is thinner than React Native's, and for enterprise apps with complex native dependencies, that gap shows up during integration sprints.
The framework decision should be documented as an Architectural Decision Record (ADR) written down with the rationale, the alternatives considered, and the tradeoffs accepted. If a development team cannot produce that document, the decision was made by default rather than by design.
# ADR-001: Framework Selection
Status: Accepted
Date: 2026-06-01
Decision: React Native
Context:
- Team has existing JS/TS experience
- App requires Stripe, Mapbox, push notifications
- No deep platform API requirements identified in discovery
Alternatives considered:
- Flutter: rejected thinner Stripe plugin support at time of decision
- Native Swift/Kotlin: rejected doubles team size, no iOS-only requirement
Consequences:
- If HealthKit integration required later → native module needed
- Agreed: revisit at sprint 8 if background sync requirements change
2. State Management Chosen for Familiarity, Not for Scale
This is where a large percentage of React Native apps quietly accumulate debt.
Redux is the most common state management choice in React Native projects often because developers know it, not because the app's state complexity warrants it. For apps with simple local state a few screens, a handful of API calls, minimal cross-component dependencies Redux introduces ceremony without benefit.
// ❌ Over-engineered: Redux for a simple user preference
const setThemeAction = createAction<Theme>('ui/setTheme');
const uiReducer = createReducer(initialState, (builder) => {
builder.addCase(setThemeAction, (state, action) => {
state.theme = action.payload;
});
});
// ✅ Better: Zustand for isolated, low-frequency state
const useUIStore = create<UIState>((set) => ({
theme: 'light',
setTheme: (t) => set({ theme: t }),
}));
The right approach is to audit the state model before writing it. What data is genuinely global auth state, user profile, app configuration versus what is local to a screen or feature module? Global state should be small and stable. Feature state should live as close to where it's used as possible.
For Android app development in Kotlin, ViewModel with StateFlow handles most cases cleanly:
// ✅ Clean: ViewModel + StateFlow no MVI ceremony needed
class OrderViewModel(private val repo: OrderRepository) : ViewModel() {
private val _state = MutableStateFlow(OrderUiState())
val state: StateFlow<OrderUiState> = _state.asStateFlow()
fun loadOrders() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
val orders = repo.getOrders()
_state.update { it.copy(orders = orders, isLoading = false) }
}
}
}
3. API Contract Assumptions That Break Under Real Conditions
Chicago's enterprise mobile market means most apps integrate with systems that were not built for mobile consumption. EHR APIs, logistics platform APIs, internal ERP systems these were designed for server-to-server or web consumption, not for mobile clients on variable network conditions.
Over-fetching on slow connections
An API endpoint that returns a 40-field object when the mobile screen needs five of those fields is not just inefficient it's a UX problem when the client is on a 3G connection in a hospital basement or a warehouse. A BFF (backend for frontend) layer shapes API responses for mobile consumption and is often worth building before launch, not retrofitting after.
No offline handling strategy
For Chicago logistics and supply chain apps driver apps, field service tools, inventory scanners offline-first architecture is a core requirement.
// WatermelonDB for offline-first React Native
import { Database, Model, field } from '@nozbe/watermelondb';
class Order extends Model {
static table = 'orders';
@field('status') status!: string;
@field('synced_at') syncedAt!: number;
@field('is_pending') isPending!: boolean; // written offline, synced later
}
// Sync when connectivity restores
async function syncPendingOrders(db: Database) {
const pending = await db.get<Order>('orders')
.query(Q.where('is_pending', true))
.fetch();
for (const order of pending) {
await pushToServer(order);
await order.markAsSynced();
}
}
Error handling written for the happy path only
API error responses in production bear little resemblance to documentation. Endpoints return 200 with error payloads. Token refresh sequences race with parallel requests:
// Swift actor prevent token refresh race condition
actor TokenRefreshCoordinator {
private var refreshTask: Task<String, Error>? = nil
func validToken() async throws -> String {
if let ongoing = refreshTask {
return try await ongoing.value // join existing refresh
}
let task = Task { try await fetchNewToken() }
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
}
4. Navigation Architecture That Doesn't Account for Deep Linking
Deep linking gets treated as a feature to add later. In apps that need to handle push notification taps, email links, or web-to-app handoffs, it's a constraint that should shape navigation architecture from sprint one.
// Define deep link config early not as an afterthought
const linking: LinkingOptions<RootParamList> = {
prefixes: ['https://app.bitcot.com', 'bitcot://'],
config: {
screens: {
Home: '',
Order: {
path: 'orders/:orderId',
parse: { orderId: (id) => Number(id) },
},
Notification: 'notifications/:notifId',
},
},
};
// Test in sprint 2, not sprint 9:
// npx uri-scheme open "bitcot://orders/123" --ios
On iOS, Universal Links require a properly configured apple-app-site-association file hosted on the domain AND a matching associated domains entitlement in the app. Teams that leave this to the end discover the domain configuration doesn't match the app's bundle ID in a production build, two days before launch.
The right time to implement and test deep linking is sprint two or three, not sprint nine.
5. CI/CD Pipeline Deferred Until the End of the Project
Continuous integration for mobile is harder to set up than for web, and it gets deferred for that reason. By the time the team gets to it, there are environment-specific workarounds, manually managed signing certificates, and build steps that only work on one developer's machine.
A mobile CI/CD pipeline configured at project start should include:
- Automated builds triggered on pull request merge
- Unit and integration test runs against each build
- Code signing managed through the pipeline, not local developer keystores
- Build artifact management for TestFlight (iOS) and internal test track (Android)
- Versioning that increments automatically based on git tags
# Fastfile configure in sprint 1, not sprint 9
lane :build_and_test do
cocoapods(clean_install: true)
run_tests(
scheme: "MyApp",
devices: ["iPhone 15 Pro"],
code_coverage: true
)
end
lane :beta do
increment_build_number(
build_number: latest_testflight_build_number + 1
)
match(type: "appstore") # cert/profile via git no local keystore
build_app(scheme: "MyApp")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
Teams that address CI/CD before launch address it under deadline pressure with a codebase that wasn't designed with automated testing in mind. That's the more expensive version of the same work.
What This Looks Like in Practice for Chicago App Projects
The mobile app development work that holds up in production whether it's a healthcare platform serving clinical workflows, a logistics tool running on a warehouse floor, or a fintech app processing real transactions in the Loop shares a common characteristic: the architectural decisions were made explicitly, documented, and validated against actual product requirements before the first feature sprint started.
Discovery isn't a project management phase. It's the engineering phase where the foundation gets designed. Skipping it is how sprint one decisions become sprint ten problems.
Chicago's app market, with its density of complex integration requirements, has less tolerance for technical debt than most. The companies operating here treat architecture as the first deliverable, not the last assumption.
For teams working through these decisions on a Chicago app project, Bitcot's mobile app development practice runs a structured discovery sprint that surfaces exactly these architectural questions before production code gets written. The case studies show how that process plays out across healthcare, fintech, and logistics.
Bitcot builds iOS, Android, and cross-platform mobile apps for product teams in Chicago and across the US.
Top comments (0)