A Startup Guide to Ship Mobile App Faster Using Flutter
Everyone is talking about building apps at FAANG scale, but we rarely talk about how to build and ship app fast in a startup scale. Hence I wrote this guideline that I wish I had read 10 years ago.
The internet is full of courses, training, interviews, blogs, and design systems videos to teach how to build mobile applications at FAANG scale (Facebook, Apple, Amazon, Netflix, and Google).
However, I have hardly seen anyone teaching how to build mobile applications for startups. Hence, this is my attempt to write a short guide, in my opinion on how we should do it.
1. Startup vs FAANG Companies
Most FAANG companies were startups, the way they operate at scale now is completely different from how they started in the beginning.
Startup can mean different things to different people. To set the context, when I refer to a startup, I am referring to a team consisting of:
One CEO/CTO/Tech Lead
One Senior Developer
One or Two Junior Developers
So, what's the goal of a startup?
To ship and iterate fast based on user feedback.
The keyword here is "fast”, but with a lot of constraints.
Startups have very limited resources in terms of time, money, developers, and QA team compared to FAANG.
So a developer in a startup has to:
Build fast, which might require skipping testing and other best practices.
Manage oneself because you don't have a product owner in the team.
Iterate and learn quickly from direct user feedback.
Do quality assurance if no QA is available.
In FAANG, most of this work is done by different teams or members. All you have to focus on FAANG as an individual contributor is coding.
The reason why I chose Flutter is because of its ability to ship products to multiple platforms with a single codebase. You don’t need to manage multiple teams for each platform. And since startups don’t have that many resources, Flutter is a perfect fit for them.1
Since the last decade, Declarative UI has become the de facto standard for UI frameworks, the majority of these guidelines can also apply to those frameworks.
To get features into users' hands, fast, we need to fix the biggest time-consuming task first. Because….
The throughput (features in hand of users) of a system is directly proportional to the time taken by the biggest bottleneck (Time consuming tasks). 2
For example, a group of scouts is hiking in a line, the pace of the group is determined by the slowest scout. Even though some scouts can walk faster, they must frequently stop or slow down because slowest is holding up the entire group. That slowest member is the bottleneck.
Similar in mobile development, releasing the app to the Android and iOS app stores frequently is a biggest time-consuming task (bottleneck) which slows us down. (Getting reviewed approved is whole another rant)
2. Get Feature in User’s Hand Fast Using CI/CD
Deploying on the web can be as simple as dragging and dropping a file onto the server. However, if you are supporting multiple platforms like Android and iOS, then without CI, in order to release the app you have to go through the following steps:
Build the APK locally
Sign it with a keystone
Manually deploy it to Google Play Store's Internal Testing
Build the IPA locally
Sign it with provisioning certificates ( Good luck and God bless you with that!!!🤣)
Manually deploy it to the App Store's TestFlight.
You might think that it’s common sense to set up CI/CD, but you know what, common sense is not that common. From my observation, very few startup teams set up CI/CD from day one. They do it almost when they are tired of building it locally.
Also, it's far easier to setup CI in the beginning because it's just a "Hello world" app. It does not have many dependencies or setup requirements. If you try to do it after a month or so, you will spend a lot of time firefighting the dependencies and OS issues and might Gave up!!
The Return of Investment on time in CI/CD is very high. Even if you are not familiar with it, I urge you to spend some time on it, even if it means moving slowly (Contradictory for a startup).
Let's do the math with an example of me working on a project for 9 months. Before we set up the CI/CD, we used to deploy one release per week on Friday, which took 2-3 hours. After spending a week setting up CI/CD, we now do a release in 15-20 minutes thrice a week.
Initially, it took me 40 hours for CI/CD setup, which might be different for you. But the point is, even if it takes a lot of time, investing that time is worth it in the long run.
Note: It took me a week because I wasn't familiar with the iOS signing and certificates process. Then I found Fastlane, and it made my life easier. You can watch the whole process in this video.
3. Choose a State Management Solution by Prototyping
After CI/CD, the second most important thing you should spend time on is choosing a state management solution. The reason is the same: this decision compounds over time. The right solution will allow you to ship faster, while the wrong one will keep blocking you more.
To find the best solution, first understand your business use case by answering the following questions. Is your app...
majority API-driven?
Local database-driven? Is it an offline-first app?
Does it require more plugins? Does it rely more on third-party plugins specific to the operating system?
Are there more forms involved?
Once you have those answers, what I would suggest here is that rather than engaging in endless theoretical discussions on which solution is best, I would recommend choosing the top 3 solutions based on open-source reputation, testing support, and how frequently it is being updated. This shouldn't take more than a day for a Senior developer to evaluate.
After that, create a prototype of 2 to 3 important screens using these 3 solutions. Remember, it does not have to be perfect or follow a certain architecture. We are creating this prototype to understand which solution fits best in our business context.
By getting hands-on experience with API calls, local caching, displaying errors, validating forms, and refreshing data on the screen, you will encounter all the edge cases and issues that will help you make better decisions. Also, it's not that once a decision is made, it cannot be reversed. It can be reversed, but the cost of this change is high.
In terms of my state management journey, I would recommend Riverpod because of its design of AsyncValue, which works really well for any kind of data source.
4. The Best Architecture Is Separation of Concern
Clean architecture, MVVM, MVP, MVI, or any other architecture pattern won't solve all your problems. Don't let your feelings drive you to build something complex just to brag about it.
Software development is all about trade-offs, and choosing the right trade-off based on the business context is the key.
That’s why I recommend building a team with at least one senior developer who has enough experience with different types of domains and architectures. This allows them to use abstract ideas from all those architectures in the project they have worked on rather than forcing a specific pattern.
From my experience, all this architecture boils down to a simple idea. Separation of Concern , that is, your ability to keep things separate based on business concerns.
A simple example would be showing a success and error message.
Instead of having one function showMessage(bool isError, String msg) with a isError flag, create two separate functions for each concern, that is, showSuccessMessage(String msg) and showErrorMessage(String msg), even if these two functions have 90% similar code.
Rome was not built in a day. Similarly, the architecture won’t be obvious from day one. It will take days or weeks to emerge an architecture pattern from the codebase. Keeping things separate from the beginning makes this process a lot easier and faster.
I really like the quote from Sandy Metz on this: 3
You should not reach for abstractions, but instead, you should resist them until they absolutely insist upon being created
Always remember, the first priority should always be solving the customer's problem, not the architecture problem. I am not saying architecture doesn’t matter because that would be too extreme.
Architecture matters, because architecture is a Maintenance cost.
Let’s say you ship your app fast and smoothly without any architecture. Now, since you ship so fast, your users will ask for more. Now you need to add more features and bug fixes from users' demand.
The problem starts here, if you don’t have any architecture. The feature, which took 1 hour to build on the first iteration, will now take 1 day for the 5th iteration without any architecture pattern.
It's an extra 7 hours per developer time value, that’s where it costs more.
Not from customers, but from developer’s time.
Below are some heuristic I follow to maintain separation of concerns.
4.1 Keep UI and Data Model Separate From Day One
I learned this the hard way…
No matter how similar the UI and API/Database model look, keep them separate from day one.
The reason is simple. They both change for totally different reasons. I’ve seen this in every single project I’ve worked on. Sometimes the UI remains the same but the API contract changes, and sometimes the UI is totally different based on user feedback but the API contract remains the same.
Reusing the same model for UI and Data is the biggest repeating mistake I’ve seen in projects. For the first few weeks, it seems fast but after that, it just becomes spaghetti code in a giant single model.
Also, it ripples changes throughout the project because these same models are passed around everywhere - widget, notifier, repository, and data source layer. So it's always better to have a separate UI and API/Database response model from day one.
Add a transformation layer in intermediate level like notifier or repository or in the model itself. For example, have a separate UI model for the PatientUI and transform it into the appropriate AddPatientRequestBody.
4.2 Make Widget Dumb
Keeping widgets dumb is a superpower. Avoid populating any UI logic inside a widget, even if it's just a null check 4. I learned this pattern from the ChangeNotifier hasListeners.
It makes the UI completely model driven and avoids having to store any state itself. When UI reacts to model changes, it creates a one-way data flow which is much easier to understand.
Let’s take a real-world example, let’s say if you want to check if a patient has booked appointments. Than instead of checking the nullability of the appointment date value in the widget, you can move this logic inside the PatientUI model like this :
You can do the same for showing relative appointment dates. That's the purpose of UI models - to have all the utilities and UI logic inside them.
This also allows you to unit test the logic without involving the widgets. You can have string manipulations, null checks, and drive one state from multiple states inside the UI models.
4.3 Create Your Own App UI Wrappers
Flutter comes with Material Design support out of the box, but the truth is that I’ve hardly seen any app which is purely built on Material Design systems.
Most startups want their design and UX to be a differentiator. So our app will always have some minor or major subtleties or brand colors which are completely different from the Material Design spec.
What if we want to show an icon against a button or a loading indicator? Hacking the material theme to our own design will cause a lot of problems.
Also, the Material Design spec breaks a lot in each Flutter version, even minor changes like, deprecation of background color to surface color can change how your app looks.
The best way to avoid this is to create our own UI wrapper on top of framework components like Button, Checkbox, LoadingIndicator, ImagePreview, Avatar, and so on, and expose all these components using a barrel file similar to how flutter/material.dart do it.
Using a barrel file allows you to hide/show specific components internally and allows for easy migration.
Most UI packages on pub.dev do the same thing. Everyone will have their own wrapping around the framework widgets and only make the necessary parts visible in their API. This makes it easy to customize and use domain names. You can leverage named constructors and factories to use different variations of the same component, like FancyButton.outline, FancyLoading.small and so on.
4.4 Create Your Own Theme Extension
With Material Theme, we start off by using primary and secondary colors properly. However, once more complex colors come into play, we start hard coding colors in widgets or adding light/dark flag conditions to support theme colors.
This happens due to two reasons. The Material spec is hard to understand and its keeps changing. It changes so fast that it's hard to keep up. Even sometimes I fail to see what property to use.
Material Theme changes rely on a simple mechanism. It keeps two objects for each theme and swaps the objects when the theme changes like this:
We can create our own theme data or use Theme Extension to manage our own brand colors. However, I would suggest going through the Material spec first to see if this can be solved by an existing property in the spec, because Material theming goes through a lot of testing and edge cases for all platforms, which we may not have considered.
5. TDD Won’t Work : Write Test After Breaking Stuff
This might be a controversial take, but TDD just doesn’t work in startups, especially at the market-fit explore stage.
I am a big advocate for TDD, because TDD is not about just testing but it forces you to think in terms of design. However, the reality in startup context is that not only the code changes a lot but the requirements and features themselves change a lot in startups. Sometimes it gets removed. So spending time on something that can pivot or get removed is not worth it.
Here is my take: To move fast, don’t write tests upfront. Instead, write tests on things which breaks a lot and are core features of the app.
For example, in a FinTech product, the most common features are currency conversion and formatting, so you better have a lot of unit tests to make sure the formatting and conversion do not break.
Another example is that, let’s say we wanted to support the design on small devices, but we didn’t have those devices for testing. So, what we can do is to write golden tests for those small sizes, which will allow us to catch UI issues on small devices quickly. This may be challenging if we don't have proper separation of concerns, which is why we need a senior developer here.
Since we have set up the CI part first, it is easier to add testing into the pipeline.
Apart from that, I would recommend you read Working Effectively with Legacy Code, Refactoring, and TDD by Example and practice on code katas. These three books will teach a lot of concepts and techniques which will allow you to work with any type of spaghetti codebase and turn it into a great design without breaking code.
6. Debugger for Production App
We know about DevTools and other debugging techniques like print, logger, or adding a breakpoint in the codebase. These are very useful when it comes to developing apps locally.
But if you are iterating fast, then you might not have a rich analytics system to log and identify what’s going on with users. So the best bet is to identify these issues on user mobile devices.
Flutter has three build modes: Debug, Release, and Profile. The release build is just what the user has, and we cannot directly debug that.
So it’s better to build a small debugger view/tool inside the app itself, which provides you with various logs and stack traces to analyze what went wrong. This also helps QA to share reports when they find a bug. It might look something like this:
One can argue about the security concerns of it. And it's valid, but in my opinion, in order to get a security threat, first you need a lot of attention and users 😆, and if you are building an MVP, then it won’t be an issue.
It’s a different story for legal products like fintech, or security-related apps. The majority of apps don’t fall into this category. For security purposes, allow this debug panel only for internal emails and make sure that you don’t log any user-sensitive data. Only collect enough data to track the issues.
To improve UX, you can use ErrorWidget.builder in Flutter, which shows up when the Flutter framework throws an error. This is seen as a red screen in debug mode and a blank screen in release mode. Having this error widget can help us identify which specific widget failed and report the error directly from it.
7. When to Use Third-Party Package
By third-party packages I mean the packages which are not officially from the framework team.
For animations, don't spend any time building things on your own. First, the most common animations (80% of them) are already available in frameworks, and second, it's far easier to build custom animations with tools like Lottie, Flame, and Rive and embed them in Flutter apps.
Even ChatGPT can generate a basic animation code. Because getting animations right by yourself is hard and time-consuming.
For UI components like dropdowns, search bars, carousels, scrolling effects, etc., look for an existing package and use it directly. If the package contains only 1-2 files, then I would recommend copying/pasting it into your code and using it from there, because relying on packages for simple tasks can also cause issues 5. If they break, then you are also affected.
For an additional layer of safety, wrap those third-party libraries in your own component as discussed above. This will consolidate the point of failure to one place.
7. Speed up the Code review
A Senior Developer plays a crucial role here. They have the most experience and they know what matters the most to save time in code review.
Avoid nitpicking : Use tools to identify common issues in the CI, such as linters, style analyzers.
For maintainability, focus on what might change in 30-40 days rather than 3-4 months. This creates a right balance between not hardcoding stuff and not thinking too far ahead of time.
If the team is in the same time zone, then have pair code reviews on a call to avoid back and forth in comments.
Use Github Permanents link in comments for faster navigation.
For example, in our app, we wanted to prototype a chat feature to add a social aspect to the app. From the beginning, we knew it was a prototype. So we kept it in a separate directory and didn’t focus much on structure and patterns in code review. We just had enough separation so that we could replace it easily.
After a few weeks, we decided to remove this feature. All we needed to do was delete the directory.
Understanding the difference between a prototype feature and a foundational feature is key here.
Don't avoid code review completely because this will create knowledge gaps and silos. Everyone should review the code so the team knows the full app context.
For more details, give a read to Anti-pattern code review to avoid common code review mistakes.
8. Draft Mode Documentation
Documentation might seem time-consuming in startups. However, if we don’t have documentation, then:
We spend more time creating existing solutions.
Searching for the same thing again and again in the codebase.
Spending time on debugging why a certain block of code is not working as expected.
I am not suggesting writing down every single detail, just important decisions which can be stored in various ways, either as architecture diagrams or descriptive TODOs in code. Don’t worry about grammar and pictures; even photos of hand-drawn diagrams or pictures of whiteboards work really well.
The best and easiest way to access these documents is to save as markdown files in the app git repo itself. It has two benefits: easy access since they are always with us and easy to track changes for who made them over a period of time.
Assume our documentation is always in draft mode and it's never published. It won’t be perfect and will always have typos, grammar, and formatting issues. The point is to have a place to add all the important decisions we are making while building the app.
If anything is outdated or does not make any sense, either delete it or update it. Don’t let unwanted documentation hang around.
9. Software Engineering is sociological in nature rather than technical
Let's get out of logical thinking and move to more human thinking.
Sometimes we spend so much time on knowing and improving the codebase that we forget that there is a person behind it. We forget to know and improve the relationship with that person.
I am bringing this up because I made this mistake myself and burned a lot of bridges with people in the past. Nothing good came out of it.
Now, how is this related to shipping fast? Well, if you have good relationships, then you have solid trust and solid trust leads to excitement and giving our best quality of work. This is where magic happens. Because if you don’t have trust, no matter what guidelines or strategy you follow, it will always lead to bad quality of output.
Whether it's AI or any software in the world, humans cannot be replaced. We are social animals. Humans build software and humans use it. What matters most is The People and the relationships we build with them.
I am not saying to treat the company as family, but rather see colleagues as an opportunity to build relationships and a professional network. Not everyone will become your friend, but approaching everyone with this mentality helps us to be kind and more human.
If your company has a budget, then have team outings yearly or every 6 months. There is a huge difference between face-to-face on-screen and off-screen. If the company can't afford this, then as an individual, try to engage in some small talk with colleagues. Ask them, “Did you watch Deadpool vs Wolverine?”
As Naval Ravikant shares the most valuable advice he’s received : “It’s the people, stupid.”
So yeah, that’s it folks.
Thank you for taking the time to read this long article. And Thanks to Dhrumil Shah, Pavan Podila and Randal Schwartz for the feedback.
If you are a startup and looking for a senior developer to build a product from scratch, mentor/build your team, help with existing issues, or fix the legacy codebase, then hit me up for any consulting work.
If you are interested in Flutter coding challenges, then you can subscribe to the my new Substack below.
Or, If you are a Flutter developer who wants to advance your Flutter skills, then I am currently running live classes called effectiveflutterdev.com. So if you are interested, then apply now.
Also, If you enjoyed this post, then would you be able to do me a quick favor and share my latest blog post with your friends and colleagues? I'd really appreciate it and I think it could be valuable to them.
Thank you!!
No solution is perfect. Flutter works in the majority of the cases, not all of them. So evaluate based on your product core proposition. Check the Comparisions.
You cannot move all the null checks. Because there might be needed null check require inside the widget for framework reasons. But 90% business logical null check can moved to UI model.