Case Study

How We Built a Banking App with 99.9% Uptime

Muhammad Shakil Muhammad Shakil
Oct 5, 2025
15 min read
How We Built a Banking App with 99.9% Uptime
Back to Blog

Last year a fintech startup reached out to us — they needed a mobile banking app. Not a prototype or an MVP, but the real thing: money transfers, bill payments, live account balances, biometric login. I still remember the first call because their CTO looked me straight in the eye (over Zoom, but still) and said: "If someone's money disappears for even a second, we're done as a company." That kind of pressure changes how you architect software.

I'm going to walk you through exactly how my team and I shipped this app in 3.5 months, how we hit 99.9% uptime after 6 months in production, and the specific decisions — architecture, security, testing — that made it possible. I'm sharing actual code from the project (anonymized, obviously) because I've read too many "case studies" that are just marketing fluff with zero implementation detail.

The Challenge: Building a Flutter Banking App

What the Client Needed

Individually, none of these requirements were new to me. But stacked together on a 4-month timeline? That's where it got interesting:

The timeline? Four months from kickoff to app store submission. I told my team we'd need to be disciplined about scope from day one — no feature creep, no "nice to haves" sneaking in. We shipped the first build to TestFlight in 3 months flat, with another two weeks for certification and the store review process.

Why We Chose Flutter for Fintech

The client originally planned to go native — Swift for iOS, Kotlin for Android. Two teams, two codebases, roughly six months. I pitched Flutter instead, and the CTO was openly skeptical. "Can a cross-platform framework handle real financial transactions?" Fair question. I'd have asked the same thing.

What convinced him: Flutter compiles to native ARM code. No JavaScript bridge, no runtime interpretation. The rendering engine draws every pixel directly through Skia (now Impeller), so performance is identical to native. I showed him our previous Flutter apps running on mid-range Android phones — smooth 60fps on a Samsung A23. For a banking app where any UI lag makes users nervous about their money, that sealed the deal.

Why Flutter for Fintech?

Flutter's native compilation means financial transactions process with zero framework overhead. Combined with platform channels for secure enclave access (Keychain on iOS, Keystore on Android), you get native-grade security with a single codebase. Check our detailed writeup on why startups choose Flutter for more context.

Clean Architecture That Actually Works in Production

I made a decision early that some of my team initially pushed back on: we spent the first two weeks doing nothing but architecture design. No feature code, no UI screens — just whiteboards and diagrams. Two weeks feels like forever when you've got a 4-month deadline. But I've been burned before by jumping into code too early on complex projects, and a banking app is not where you want to discover your architecture can't handle concurrent transactions.

Feature-First Folder Structure

We organized the codebase by feature, not by type. Each feature — auth, transfers, bills, dashboard — owns its entire vertical slice, from the API client down to the UI widgets. If you've read our complete Clean Architecture guide, you'll recognize this pattern.

// Feature-first project structure
lib/
├── core/
│ ├── network/
│ │ ├── api_client.dart // Dio instance with interceptors
│ │ ├── certificate_pinner.dart // SSL pinning logic
│ │ └── circuit_breaker.dart // Retry + fallback patterns
│ ├── security/
│ │ ├── encryption_service.dart
│ │ ├── biometric_service.dart
│ │ └── session_manager.dart
│ ├── storage/
│ │ └── secure_storage.dart // flutter_secure_storage wrapper
│ └── di/
│ └── providers.dart // Riverpod provider definitions
├── features/
│ ├── auth/
│ │ ├── data/ // Repository impl, DTOs, API
│ │ ├── domain/ // Entities, use cases, repo interface
│ │ └── presentation/ // Screens, widgets, state
│ ├── dashboard/
│ ├── transfers/
│ ├── bills/
│ └── settings/
└── shared/
 ├── widgets/ // Reusable UI components
 └── models/ // Shared data models

What this gave us in practice: when one of my developers was working on bill payments, he couldn't accidentally break the transfer flow even if he tried. Each feature directory is its own mini-app — business logic, API calls, UI — all connected through interfaces in the domain layer. When we onboarded a freelancer for the dashboard module 6 weeks in, he was productive on day two because he only needed to understand his own feature slice.

Dependency Injection with Riverpod

I picked Riverpod for state management and DI, and I'll be honest — it was a controversial call internally. Two of my developers were BLoC veterans and didn't want to switch. But here's the thing: in a banking app, a runtime ProviderNotFoundException in production means someone might not see their balance. Riverpod catches those at compile time. That alone ended the debate. (I wrote a detailed comparison of GetX vs BLoC vs Riverpod if you're weighing the same decision.)

// Transfer feature providers
final transferRepositoryProvider = Provider<TransferRepository>((ref) {
 final apiClient = ref.watch(apiClientProvider);
 final localDb = ref.watch(driftDatabaseProvider);
 return TransferRepositoryImpl(apiClient, localDb);
});

final executeTransferProvider = Provider<ExecuteTransfer>((ref) {
 return ExecuteTransfer(ref.watch(transferRepositoryProvider));
});

final transferStateProvider = StateNotifierProvider<TransferNotifier, TransferState>((ref) {
 return TransferNotifier(
 executeTransfer: ref.watch(executeTransferProvider),
 sessionManager: ref.watch(sessionManagerProvider),
 );
});

Every dependency flows through Riverpod's provider graph. Swap the real API for a mock in tests? Override one provider. Add caching later? Wrap the repository provider. By the end of the project we had 80+ providers and the dependency graph was still readable — something I don't think we could have maintained with manual service locators.

Architecture Decision

I've used BLoC on three previous projects and it's solid, but Riverpod won here for three reasons: compile-time safety, way less boilerplate for async operations, and built-in provider overrides for testing. On a 4-person team with a tight deadline, writing half as much state management code matters. If you're considering the switch, I wrote a Riverpod 3.0 migration guide based on our experience.

Security Measures for Flutter Banking Apps

This is the section I care about most personally. Security in a banking app isn't something you bolt on at the end — it's six layers that either work together or don't work at all. I based our approach on the OWASP Mobile Top 10 and built every layer before writing a single feature screen. The app went through three independent penetration tests from different firms. Zero critical findings across all three.

Certificate Pinning

I'll be blunt: if your banking app doesn't have certificate pinning, you shouldn't ship it. Period. Someone on public Wi-Fi at a coffee shop is one MITM attack away from having their session hijacked. Certificate pinning forces the app to only talk to servers presenting our specific SSL certificate — even if an attacker has compromised the device's entire certificate authority store.

// Certificate pinning with Dio
import 'package:dio/dio.dart';
import 'dart:io';

class CertificatePinner {
 static const _expectedFingerprint =
 'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';

 static Dio createPinnedClient() {
 final dio = Dio(BaseOptions(
 baseUrl: 'https://api.bankingapp.com/v1',
 connectTimeout: const Duration(seconds: 15),
 receiveTimeout: const Duration(seconds: 15),
 ));

 (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
 final client = HttpClient();
 client.badCertificateCallback = (cert, host, port) {
 // Verify certificate fingerprint matches our pinned cert
 final fingerprint = cert.sha256Fingerprint;
 return fingerprint == _expectedFingerprint;
 };
 return client;
 };

 return dio;
 }
}

One thing I learned from a previous project the hard way: always ship with a backup certificate hash. We include both primary and backup fingerprints, so when the server certificate rotates, existing app versions keep working seamlessly. Without this, a cert rotation can lock every user out until they update the app.

Encrypted Local Storage

Any data stored on-device needs AES-256 encryption. We wrapped flutter_secure_storage in a service layer that handles key management through the platform's secure enclave — Keychain on iOS, Keystore on Android.

// Secure storage service
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
 final FlutterSecureStorage _storage;

 SecureStorageService()
 : _storage = const FlutterSecureStorage(
 aOptions: AndroidOptions(encryptedSharedPreferences: true),
 iOptions: IOSOptions(
 accessibility: KeychainAccessibility.first_unlock_this_device,
 ),
 );

 Future<void> saveToken(String token) async {
 await _storage.write(key: 'auth_token', value: token);
 }

 Future<String?> getToken() async {
 return await _storage.read(key: 'auth_token');
 }

 Future<void> clearAll() async {
 await _storage.deleteAll();
 }
}

Pay close attention to KeychainAccessibility.first_unlock_this_device on iOS — I missed this on an earlier project and the pen testers flagged it. It means tokens are only accessible after the user unlocks their phone at least once after a reboot. Without it, someone who clones the device storage could extract credentials from a powered-off phone.

Biometric Authentication Setup

I get asked about biometric auth in Flutter constantly, so let me clear it up: the local_auth package talks directly to the platform's native biometric APIs. Fingerprint data never leaves the device — it's all verified by the secure enclave hardware. We use it as a second factor beyond the PIN code, not a replacement.

// Biometric authentication
import 'package:local_auth/local_auth.dart';

class BiometricService {
 final LocalAuthentication _auth = LocalAuthentication();

 Future<bool> get isAvailable async {
 final canCheck = await _auth.canCheckBiometrics;
 final isDeviceSupported = await _auth.isDeviceSupported();
 return canCheck && isDeviceSupported;
 }

 Future<bool> authenticate() async {
 try {
 return await _auth.authenticate(
 localizedReason: 'Verify your identity to continue',
 options: const AuthenticationOptions(
 stickyAuth: true, // Keep auth valid through app lifecycle
 biometricOnly: false, // Allow PIN/pattern as fallback
 useErrorDialogs: true,
 ),
 );
 } catch (e) {
 return false;
 }
 }
}

Security Tip

Set stickyAuth: true for banking apps. Without it, if the user switches to another app mid-authentication and comes back, the auth dialog silently fails. With sticky auth, it resumes where the user left off. Small detail, massive UX difference. For a deeper look at Flutter security, see our complete app security guide.

Session Management and Device Binding

I spent a full day just on session management after the first pen test flagged our initial implementation as too lenient. Here's what we ended up with — and these are things financial regulators specifically check during compliance audits:

Offline-First Architecture with Drift

This feature wasn't in the original spec. I pushed for it because the client's target market included rural areas in Pakistan where 3G drops constantly. The client said "our users have good internet" — I told them no, they don't, not always. After launch, offline transfers became the most praised feature in user reviews. I built the entire offline layer on Drift (formerly Moor) for type-safe SQLite access with Dart code generation. If you haven't used Drift before, I wrote an offline-first Flutter apps guide that covers the full setup.

The Transaction Queue

When a user initiates a transfer without internet, we store it in a local queue with all the data needed to execute it later:

// Drift table for offline transaction queue
class PendingTransactions extends Table {
 IntColumn get id => integer().autoIncrement()();
 TextColumn get idempotencyKey => text().unique()();
 TextColumn get recipientAccount => text()();
 RealColumn get amount => real()();
 TextColumn get currency => text().withDefault(const Constant('PKR'))();
 TextColumn get note => text().nullable()();
 IntColumn get status => intEnum<QueueStatus>()();
 DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 IntColumn get retryCount => integer().withDefault(const Constant(0))();
}

enum QueueStatus { pending, processing, completed, failed }

See that idempotencyKey column? That's the most important field in the entire app. I cannot stress this enough. Every transaction gets a UUID generated client-side before it enters the queue. When the server receives it, it checks: "Have I processed this key already?" If yes, it returns the original result instead of executing the transfer again. I've seen banking apps without idempotency keys — a network timeout during a transfer leads to a retry, and suddenly the user gets charged twice. We had zero duplicate transactions in 6 months of production.

Sync Engine

The sync engine runs in a background Dart isolate so it doesn't block the UI thread:

// Simplified sync engine
class TransactionSyncEngine {
 final PendingTransactionDao _dao;
 final TransferApiClient _api;

 Future<void> syncPendingTransactions() async {
 final pending = await _dao.getPendingTransactions();

 for (final tx in pending) {
 if (tx.retryCount >= 3) {
 await _dao.markFailed(tx.id);
 continue; // Stop retrying after 3 attempts
 }

 try {
 await _dao.markProcessing(tx.id);

 final result = await _api.executeTransfer(
 recipientAccount: tx.recipientAccount,
 amount: tx.amount,
 idempotencyKey: tx.idempotencyKey,
 );

 if (result.success) {
 await _dao.markCompleted(tx.id);
 } else {
 await _dao.incrementRetry(tx.id);
 }
 } catch (e) {
 await _dao.incrementRetry(tx.id);
 // Exponential backoff handled by the caller
 }
 }
 }
}

Users see a clear "Pending" badge on queued transactions, with a timestamp showing when it was created. The moment the transaction syncs, the badge changes to a green checkmark with the server timestamp. No ambiguity about whether their money moved or not.

Sync Performance

Our sync engine processes the queue in under 200ms per transaction on a mid-range Android device. Using a background isolate means the UI stays at a smooth 60fps even while syncing a backlog of 20+ offline transactions.

Testing Strategy for Financial Apps

I have a rule on every project: if you can't test it, don't ship it. On a banking app, that rule becomes "if you can't prove it handles every failure mode, someone loses money." Network timeout during a transfer, biometric cancellation mid-flow, expired tokens, two transfers hitting the server at the same millisecond — every one of these had to be covered before I'd approve a release. I wrote about our general testing strategy before, but let me walk through what we did specifically for this project.

The Testing Pyramid

I weighted heavily toward fast unit tests because they're what catch bugs at 2 AM when you're pushing a hotfix:

// Example: Testing the transfer use case
void main() {
 late ExecuteTransfer useCase;
 late MockTransferRepository mockRepo;

 setUp(() {
 mockRepo = MockTransferRepository();
 useCase = ExecuteTransfer(mockRepo);
 });

 test('should fail when amount exceeds daily limit', () async {
 // The daily limit is 500,000 PKR
 final result = await useCase.execute(
 recipientAccount: '1234567890',
 amount: 600000.00,
 currency: 'PKR',
 );

 expect(result.isLeft(), true);
 result.fold(
 (failure) => expect(failure, isA<DailyLimitExceeded>()),
 (_) => fail('Should have failed'),
 );
 });

 test('should generate idempotency key for offline queue', () async {
 when(mockRepo.hasConnection).thenReturn(false);

 final result = await useCase.execute(
 recipientAccount: '1234567890',
 amount: 5000.00,
 currency: 'PKR',
 );

 expect(result.isRight(), true);
 verify(mockRepo.queueTransaction(any)).called(1);
 });
}

Integration Testing Real Money Flows

Nobody told me this when I started building fintech apps, so I'll tell you: test with real money. Tiny amounts — we used 1 PKR transfers — but real money flowing through real payment rails. Simulators and mocks don't reproduce the edge cases that live gateways throw at you. I'm talking about situations like: the gateway returns a success response but the webhook arrives 30 seconds late, or the transfer completes on the bank's side but the balance update packet gets dropped. We caught both of these in staging because we were using real payment infrastructure, and we built retry logic to handle them. You won't find those bugs with mocks.

I set a non-negotiable rule in our CI: any pull request that drops code coverage below 85% gets automatically rejected. No exceptions, not even hotfixes from me. We ended up at 92% overall and held that line throughout the project. My team hated it at first, but after two months nobody complained because the bugs they weren't finding in production made it worth the extra 20 minutes per PR.

Performance Optimizations That Matter

Account balances change constantly, transactions appear in real time, exchange rates shift throughout the day. The app has to reflect all of this without killing the user's battery. I covered Flutter performance optimization in depth in another article, but the banking-specific optimizations I did here were a different challenge from general app tuning.

Real-Time Updates via WebSockets

My first approach was polling the server every 5 seconds for balance updates. It worked, but it was wasteful — 90% of the time nothing had changed, and we were burning data and battery for nothing. I switched to WebSocket connections that push updates the instant they happen server-side:

// WebSocket connection for real-time balance updates
class BalanceStream {
 WebSocketChannel? _channel;
 final SecureStorageService _storage;

 Future<Stream<BalanceUpdate>> connect() async {
 final token = await _storage.getToken();
 _channel = WebSocketChannel.connect(
 Uri.parse('wss://api.bankingapp.com/ws/balance'),
 protocols: ['bearer', token ?? ''],
 );

 return _channel!.stream
 .map((data) => BalanceUpdate.fromJson(jsonDecode(data)))
 .handleError((error) {
 // Reconnect with exponential backoff
 _reconnect();
 });
 }

 void _reconnect() {
 Future.delayed(const Duration(seconds: 5), () => connect());
 }
}

I added a fallback I'm glad I thought of before launch: if the WebSocket drops for more than 30 seconds, the app silently switches to polling every 10 seconds until the socket reconnects. Users never see stale data for more than 10 seconds, even on the flakiest connections. Our FCM push notifications guide covers how we handle the notification side of this real-time architecture.

Widget Rebuild Control

This was a performance disaster I discovered in week 8. The dashboard screen shows a transaction list with a live balance counter at the top. Every time the balance updated via WebSocket, Flutter was rebuilding all 200+ transaction list items, not just the balance widget. I fixed it with Riverpod's select modifier:

// Only rebuilds when the balance value actually changes
class BalanceDisplay extends ConsumerWidget {
 const BalanceDisplay({super.key});

 @override
 Widget build(BuildContext context, WidgetRef ref) {
 // select() means this widget ONLY rebuilds when balance changes
 // not when other account fields (name, lastLogin, etc.) update
 final balance = ref.watch(
 accountProvider.select((account) => account.balance),
 );

 return Text(
 'PKR ${balance.toStringAsFixed(2)}',
 style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
 );
 }
}

That single .select() call reduced unnecessary rebuilds by about 70% on the dashboard. The transaction list went from rebuilding every item on every balance update to only touching the balance number at the top. I should have caught it earlier, but that's what profiling tools are for.

Performance Numbers

Before I fixed the rebuild issue: dashboard had ~340 widget rebuilds per balance update. After: ~45. Frame render time dropped from 22ms to 8ms on a Samsung A53. I practically lived in the Flutter DevTools performance tab for three days straight.

Common Pitfalls Building Fintech with Flutter

After shipping this app and talking with other Flutter developers building fintech, I keep seeing the same mistakes repeated. Here's what to avoid if you're starting a similar project:

  1. Storing tokens in SharedPreferences. SharedPreferences is plaintext. On a rooted device, anyone can read it. Use flutter_secure_storage — always. There's no performance reason to skip encryption.
  2. Skipping the offline queue. "Our users have good internet." No, they don't. Not always. A transfer that fails silently because the connection dropped mid-request is how you get three angry support tickets about "missing money."
  3. No idempotency keys on transactions. Network timeout during a transfer → user retries → server processes it twice → two debits, one credit. The fix is a UUID generated client-side before the first attempt. The server deduplicates based on this key.
  4. Testing only the happy path. Your unit tests pass when the API returns 200. What happens when it returns 429 (rate limit), 503 (server down), or takes 45 seconds to respond? Test every failure mode.
  5. Ignoring hot-restart security. After a hot restart during development, you might accidentally ship a debug build that skips certificate pinning. Our CI only builds release mode, and the debug build deliberately breaks API calls with a big red "DEBUG MODE" banner.
  6. Floating-point math for money. Don't use double for currency amounts. 0.1 + 0.2 != 0.3 in IEEE 754. We used integer-based amounts in the smallest unit (paisa) and only converted to PKR for display.

Results After 6 Months in Production

Six months after launch, here's where the app stands:

"The offline capability was the feature we didn't know we needed. Our users in rural areas can initiate transfers even without internet, and they process automatically when connectivity returns. That single feature drove our best growth metric." — Client CTO

What I'd Do Differently (and What I'd Do Again)

  1. Those two weeks of architecture were the best decision I made. When the client asked for a "quick balance check" feature 3 months in, my developer shipped it in 2 days because the architecture already supported it. If we'd started coding immediately, that same request would have taken weeks of refactoring.
  2. Test with real money from week one. Our staging environment used 1 PKR transfers on live payment rails. Simulators don't reproduce the timing issues, webhook delays, and timeout patterns that production gateways throw at you. I'd start even earlier next time.
  3. Push for offline-first even when the client says they don't need it. They told me their users had good internet. I insisted on offline capability anyway. It became the app's highest-rated feature. Trust your instinct on UX — users don't always know what they need until they have it.
  4. Security goes into sprint zero, not sprint "someday." Certificate pinning, encrypted storage, and biometric auth were in the architecture before we wrote a single feature. Adding security after the fact means rewriting your entire data layer — I've done that on a non-financial project once, and it took longer than building it right the first time.
  5. Monitor everything in production. I set up Firebase Crashlytics with custom keys for every transaction state. When a crash happens, I see exactly which screen, which transaction, and which error — not just a stack trace. Two bugs that would have taken days to reproduce were fixed within hours because of this telemetry.

Building a Fintech App?

I've personally led 12+ financial application projects with zero security incidents. If you're planning a banking, payment, or trading app in Flutter, reach out and let's talk architecture — I'd rather help you avoid the mistakes upfront than fix them after launch.

Related Reading

Frequently Asked Questions

What architecture is best for a Flutter banking app?

In my experience, Clean Architecture with a feature-first folder structure works best. Each feature — auth, transfers, bills — gets its own data, domain, and presentation layers. I pair this with Riverpod for state management because it catches dependency errors at compile time. This structure isolates failures so a bug in bill payments can't affect transfers. We used this pattern on our banking project and shipped in 3.5 months.

How do you achieve 99.9% uptime in a Flutter mobile app?

It comes down to planning for failure at every level. On the app side: circuit breaker patterns for API calls, offline-first architecture with Drift for local caching, and graceful degradation when the backend goes down. On the server side: redundant infrastructure and blue-green deployments. We also use Firebase Crashlytics for real-time crash monitoring so we catch issues within minutes, not days.

What security measures are essential for Flutter banking apps?

I implement six layers on every fintech project: SSL certificate pinning to stop MITM attacks, biometric auth via local_auth, AES-256 encrypted storage through flutter_secure_storage, root/jailbreak detection, screenshot prevention on sensitive screens, and automatic session timeouts after 5 minutes of inactivity with token rotation on every API call. Then I bring in a third-party firm for OWASP-compliant penetration testing before any release.

How do you handle offline transactions in a Flutter banking app?

I use Drift (formerly Moor) to create a local SQLite queue. When the user initiates a transfer offline, it goes into the queue with a unique idempotency key — that key prevents duplicate processing when connectivity returns. A background Dart isolate picks up the queue and processes transactions in order. The important UX detail: show clear "Pending" indicators so users always know whether their money has moved yet.

Is Flutter good enough for production banking apps?

Absolutely. I can point to our own project as proof: 50,000+ daily active users, 99.9% uptime, 0.02% crash rate, zero security incidents across three independent pen tests, and a 4.8-star rating on both stores. Flutter compiles to native ARM code with no bridge overhead, so performance is indistinguishable from a native Swift/Kotlin app. The single codebase also cut our delivery time roughly in half.

What testing strategy works best for Flutter fintech apps?

I follow a strict testing pyramid: heavy on unit tests (400+ in our case), solid widget test coverage (200+), and focused integration tests for end-to-end money flows (50+). The key rule I enforce is an 85% minimum code coverage in CI — any PR that drops below gets rejected automatically. And critically, test against real payment rails with real (tiny) amounts. Mocks won't surface the timing and race condition bugs that live gateways produce.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.