Building a mobile app that can grow with your business is harder than most tutorials make it look. You start with a clean idea, pick a framework, and then reality sets in: state gets messy, the codebase gets harder to navigate, and the app that felt fast at 500 users starts creaking at 50,000.
This post is a real breakdown of how we approached building a scalable Flutter app from scratch at FBIP. We cover the decisions we made, the architecture we chose, and the mistakes we caught early enough to fix. If you are planning a Flutter project or trying to understand what good app architecture actually looks like in practice, this is for you.
Why Flutter Was the Right Choice for This Project
The client came to us with a logistics management app concept targeting both Android and iOS users. They had a tight budget and a six-month deadline. That combination rules out a lot of approaches.
Flutter, Google’s open-source UI toolkit, lets you write one codebase that compiles to native ARM code for both platforms. According to the Flutter showcase, teams consistently report 40–60% reduction in development time compared to building two separate native apps. That was the deciding factor.
But the bigger reason we chose Flutter was its widget tree model. Everything in Flutter is a widget, which sounds limiting until you realize it gives you total control over every pixel on screen — no platform-specific rendering quirks to fight. For a logistics dashboard that needed custom charts, real-time tracking maps, and dynamic list views, that level of control mattered.
Phase 1 — Planning the Architecture Before Writing a Single Line of Code
The most important work on this project happened before anyone opened VS Code. We spent two full weeks on architecture decisions. Skipping this step is how most apps end up as unmaintainable spaghetti six months in.
Choosing a State Management Approach
Flutter offers several state management options: setState, Provider, Riverpod, BLoC, GetX, and others. Each has a place. Here’s how we decided:
How to Pick a Flutter State Management Solution
- Small app, single developer: Provider or Riverpod keeps things clean without overhead.
- Team of 3+ developers: BLoC (Business Logic Component) enforces clear separation and makes code reviews easier.
- Real-time data streams: BLoC’s stream-based model maps naturally onto live data.
- Rapid prototyping: GetX moves fast but scales poorly; avoid it for production apps.
We went with BLoC + Cubit. The logistics app had real-time delivery tracking, multiple user roles, and complex filter states. BLoC gave us predictable, testable state changes. Every UI interaction fires an event, the BLoC processes it, and emits a new state. Simple to reason about, easy to test.
Folder Structure That Doesn’t Fall Apart
We use a feature-first folder structure rather than a layer-first one. Instead of folders named /models, /views, /controllers at the top level, each feature gets its own folder containing all three:
lib/
├── core/
│ ├── theme/
│ ├── router/
│ └── utils/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ ├── tracking/
│ └── orders/
└── main.dart
This follows clean architecture principles. When a new developer joins, they can find everything related to “tracking” in one place. When you need to delete or refactor a feature, it’s contained. This matters when your app grows from 10 screens to 50.
Phase 2 — Building the Core: Auth, Routing, and API Layer
Authentication
We used Firebase Authentication for this project. It handles token refresh, session persistence, and social logins without us building it from scratch. On the Flutter side, we wrapped Firebase calls inside a repository interface so the app never calls Firebase directly from the UI layer. If we ever swap Firebase for a custom auth server, we change one file.
Navigation and Routing
Flutter’s built-in Navigator 2.0 is powerful but verbose. We used go_router, which is now officially maintained by the Flutter team. It gives you declarative URL-based routing, deep linking support, and redirect logic for authenticated routes — all in a readable format.
Deep linking was non-negotiable for this client. Delivery drivers needed to tap a notification and land directly on a specific order screen, not the home screen. go_router made that straightforward.
API Layer with Retrofit and Dio
We built a typed HTTP client using Dio with Retrofit for code generation. Retrofit generates type-safe API interfaces from annotations — you define your endpoints once and get compile-time errors if you misuse them. Combined with a global interceptor for token injection and error handling, the API layer is about 200 lines of code that handles everything from auth errors to network timeouts.
“The API layer should be boring. If debugging a network call requires reading multiple files, something’s wrong.” — a principle we live by at FBIP.
Phase 3 — Scalability Where It Hurts: Data, Lists, and Real-Time Updates
Handling Large Lists Without Killing Performance
The app needed to display hundreds of delivery orders in a scrollable list. Flutter’s ListView.builder is lazy by default — it only builds widgets currently on screen — but we went further:
- Used pagination to fetch 20 items at a time instead of loading everything upfront.
- Wrapped list items in const constructors wherever possible so Flutter can skip rebuilding them entirely.
- Used RepaintBoundary around complex list items to isolate repaints.
The result: smooth scrolling on mid-range Android devices, which is typically where Flutter apps struggle most.
Real-Time Tracking with WebSockets
The live delivery tracking screen needed sub-second location updates. REST polling every few seconds introduces noticeable lag and unnecessary server load. We used WebSocket connections through the web_socket_channel package, with the BLoC listening to the stream and emitting position updates to the map widget.
One thing we got right early: wrapping the WebSocket in a service class with automatic reconnection logic. Mobile connections drop constantly. If your app doesn’t handle that gracefully, users assume it’s broken.
Local Caching with Hive
Delivery drivers often work in areas with poor connectivity. The app needed to show the last known order data even when offline. We used Hive, a lightweight NoSQL database for Flutter, to cache API responses locally. When the network comes back, the app syncs and refreshes.
This isn’t complex to build, but it has to be planned upfront. Adding offline support to an app that wasn’t designed for it is painful.
Phase 4 — Testing, and Why We Treated It as Non-Negotiable
Flutter has excellent testing support at three levels: unit tests, widget tests, and integration tests. Here’s what we actually shipped with:
Flutter Testing Strategy for Production Apps
- Unit tests for every BLoC and repository — fast, cheap to write, catches logic errors instantly.
- Widget tests for critical UI components like the order status card and the auth forms.
- Integration tests for the two or three flows that absolutely cannot break: login, order acceptance, and status update.
- Flutter Analyze + Dart linting configured in CI to catch code style issues before they reach review.
We used GitHub Actions for CI. Every pull request runs the full test suite. If tests fail, the PR doesn’t merge. That sounds strict until you ship to 10,000 users and realize a regression in production costs ten times more than catching it in CI.
Phase 5 — Performance Optimization Before Launch
Two weeks before launch, we ran Flutter’s DevTools profiler on real devices. A few things we fixed:
- The dashboard screen was rebuilding its entire widget tree on every location update. We split it into smaller widgets so only the map marker rebuilds.
- Image loading wasn’t cached. We added cached_network_image to cache delivery photos and avoid re-downloading on scroll.
- We reduced the APK size by about 30% using Dart’s tree shaking and removing unused font weights from the font package.
On iOS, we ran Instruments to check for memory leaks, especially around the map view. Found one stream subscription that wasn’t being cancelled on widget disposal. Easy fix, but left unchecked it would have caused memory to grow with every navigation cycle.
What We’d Do Differently
No project is perfect. Here’s what we’d change:
- Start with flavors earlier. Flutter’s app flavors let you have separate dev, staging, and production configurations. We added them mid-project, which required some rework.
- Set up error logging on day one. We integrated Sentry near the end of the project. Doing it from the start would have caught a few obscure edge cases during internal testing rather than after launch.
- Define the design system before building screens. We standardized colors, text styles, and spacing after building several screens and had to go back and clean up inconsistencies.
Results After Launch
The app went live for the client’s fleet of 200+ drivers. Three months post-launch, crash rate sits below 0.4% (Google’s benchmark for a stable app is under 1%). Average session duration is 22 minutes, which makes sense for drivers using it throughout a shift. The client has since requested two new feature modules, and because of the feature-first architecture, adding them didn’t require touching existing code.
That’s the real measure of a well-built Flutter app: not how it works at launch, but how easy it is to change six months later.
At FBIP, application development — including Flutter — is one of our core service areas. If you’re scoping a mobile project and want to talk through architecture decisions before you start building, our team is happy to get into the specifics.
Frequently Asked Questions
Q1. How long does it take to build a scalable Flutter app from scratch?
It depends on scope, but a production-ready Flutter app with solid architecture typically takes 3 to 6 months. Simple apps with basic screens can launch in 6 to 10 weeks. Apps with real-time features, complex state, and offline support take longer. Planning the architecture properly in the first two weeks saves significant time later.
Q2. Is Flutter good for large-scale apps, or just small projects?
Flutter works well at scale when the architecture is right. Companies like BMW, eBay Motors, and Alibaba use Flutter in production. The key is choosing the correct state management pattern (BLoC works well for large teams) and following clean architecture principles from the start, not as an afterthought.
Q3. What is the best state management for a scalable Flutter app?
For most production apps with multiple developers, BLoC (Business Logic Component) is the most reliable choice. It enforces a clear separation between UI and business logic, makes unit testing straightforward, and handles complex state flows like real-time data streams cleanly. Riverpod is a good alternative for smaller teams.
Q4. How does Flutter handle offline functionality?
Flutter doesn’t include offline support by default, but packages like Hive, Isar, and sqflite let you cache data locally. The pattern is to save API responses to local storage and serve them when there’s no connection, then sync when connectivity returns. This needs to be designed into the app architecture from the beginning.
Q5. What are the most common Flutter performance problems and how do you fix them?The most frequent issues are unnecessary widget rebuilds, uncached images, and memory leaks from unDisposed streams. Flutter DevTools’ performance overlay and timeline view help identify these. Solutions include using const widgets, RepaintBoundary, the cached_network_image package, and ensuring all stream subscriptions are cancelled in dispose methods.





