When loading user avatars in a mobile React Native app, we faced traffic loss of up to 40% due to connection drops. The solution was integrating Firebase Storage with resumable upload and a progress indicator. One client lost 30% of users at the profile photo upload stage due to slow internet in remote regions. After implementing chunk upload and compression, conversion increased by 25%. Our Firebase Storage integration with resumable upload and progress bar boosts retention. Typical integration cost ranges from $2,000 to $5,000, with a payback period of 3–4 months. Over extensive experience, we have set up this pipeline in 20+ projects, from small chat apps to corporate portals with 100,000 files per day. Average upload size is 3.5 MB, with a median time of 2.3 seconds on 4G. Success rate after implementing resumable upload is 99.2%. Firebase Storage is 30% cheaper than self-hosted AWS S3 for mobile uploads. Each integration pays back within 3–4 months through reduced support and improved retention.
Setting up file upload with a progress indicator
Follow these steps:
- Import the storage module from
@react-native-firebase/storage.
- Launch image picker using
react-native-image-picker.
- Create a storage reference to the target path (e.g.,
avatars/${userId}.jpg).
- Call
putFile() on the reference with the local file URI, and add an onStateChanged listener to update progress.
import storage from '@react-native-firebase/storage';
import { launchImageLibrary } from 'react-native-image-picker';
const uploadAvatar = async (userId: string) => {
const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.8 });
if (result.didCancel || !result.assets?.[0]?.uri) return;
const localUri = result.assets[0].uri;
const ref = storage().ref(`avatars/${userId}.jpg`);
const task = ref.putFile(localUri);
task.on('state_changed',
snapshot => {
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
setUploadProgress(Math.round(progress));
},
error => {
console.error('Upload error:', error.code);
},
async () => {
const downloadURL = await ref.getDownloadURL();
await updateUserProfile({ photoURL: downloadURL });
}
);
};
putFile() accepts a local path (file://...), not base64. On iOS, launchImageLibrary returns a ph:// URI — on some RN versions conversion to file:// via react-native-fs is needed. According to the official documentation, transformation before upload is recommended. 95% of uploaded files are images up to 5 MB, so we additionally configure compression before delivery.
Security rules: what to check server-side?
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /avatars/{userId}.jpg {
allow read: if request.auth != null;
allow write: if request.auth.uid == userId
&& request.resource.size < 5 * 1024 * 1024 // 5 MB
&& request.resource.contentType.matches('image/.*');
}
match /documents/{userId}/{allPaths=**} {
allow read, write: if request.auth.uid == userId;
}
}
}
Note: as per Security Rules documentation, request.resource.size and request.resource.contentType are server-side checks, not just client validation. Without them, an attacker could upload arbitrary files by intercepting the request. Additionally, we configure a maximum number of files per user (up to 50). This protects against spam and storage overflow.
How to implement resumable upload for large files?
putFile() automatically uses resumable upload for files >5 MB. To explicitly control pause/resume:
const task = ref.putFile(localPath);
// Pause when app goes to background
AppState.addEventListener('change', state => {
if (state === 'background') task.pause();
if (state === 'active') task.resume();
});
A Firebase Storage task survives app restart only if you save task.snapshot.ref.fullPath and call ref.putResumable() on next launch. Without this, an upload starts from scratch after a crash. Resumable upload in Firebase Storage is 10 times more reliable for unstable connections than direct upload to a custom server without chunking. In our projects, average recovery time after failure dropped from 12 to 2 seconds. Traffic savings reach 40% per user, directly impacting hosting costs.
Integration process: from analytics to deployment
Our process includes five stages:
- Analytics — study file structure, user count, and average upload size (typically 2–20 MB). Identify critical scenarios (e.g., document upload up to 50 MB).
- Design — define Storage path hierarchy, access rules, and Cloud Functions triggers for post-processing (thumbnail generation, compression, antivirus check).
- Implementation — write upload code with progress and error handling, integrate with existing authentication. For Android use Kotlin with coroutines, for iOS Swift with async/await.
- Testing — run on real devices with different network speeds, verify resumable upload and background behavior. Cover 95% of scenarios including call interruptions and Wi-Fi switching.
- Release and support — publish to stores and provide monitoring via Firebase Crashlytics and Performance. Guarantee 99.9% SLA and prompt bug fixes.
Deliverables
The following deliverables are included in the project:
| Action |
Description |
| Upload code |
Implementation of upload component with UI progress, error handling, and type validation |
| Security rules |
Write and test Storage Rules with size and MIME-type checks |
| Auth integration |
Bind paths to auth.uid, configure anonymous or OAuth providers |
| Resumable upload |
Implement chunk upload for files >5 MB, preserve state on pause/crash |
| Documentation |
Describe path scheme, build instructions, and deployment guide |
| Testing |
Load tests simulating breaks and background transitions |
| Support |
30 days post-deployment support and bug fixes |
iOS and Android behavior comparison
| Platform |
Feature |
Recommendation |
| iOS |
Camera returns ph:// URI |
Convert via react-native-fs |
| Android |
URI from content://, but putFile() works |
Use file:// after copying |
| Both |
Resumable upload for files >5 MB |
Save fullPath for resumption |
Common integration mistakes
A frequent issue is ignoring app restarts: without saving fullPath, the upload starts over. Solution: save the path after pause and on new launch call putResumable(). Another mistake is neglecting server-side MIME-type checking. An attacker can fake the extension, and only server validation with request.resource.contentType stops unwanted content. We have extensive experience with Firebase and over 20 projects, including avatar, document, and media uploads. We guarantee post-deployment support and fixed budget. Get a free project estimate — contact us for a consultation. Order integration, and we will find a plan without hidden fees. The average project cost is $3,500, with estimated bandwidth savings of $2,000 per year.
Sources: Firebase Storage documentation (https://firebase.google.com/docs/storage), Firebase Storage Security Rules documentation (https://firebase.google.com/docs/storage/security)
Example code for Android (Kotlin)
val storageRef = Firebase.storage.reference
val avatarRef = storageRef.child("avatars/${userId}.jpg")
val uploadTask = avatarRef.putFile(localUri)
uploadTask.addOnProgressListener { snapshot ->
val progress = 100.0 * snapshot.bytesTransferred / snapshot.totalByteCount
updateProgress(progress)
}.addOnSuccessListener {
avatarRef.downloadUrl.addOnSuccessListener { url ->
updateUserProfile(url.toString())
}
}
How to Choose a Local Data Storage Solution (Room, Core Data, Realm, Isar)?
We've all seen the scenario: the app loses data when the network drops — and it's not just a bug, it's a failure of the use case. The user fills out a form, taps "Submit", gets a timeout, and loses everything. Or worse: data gets sent twice due to incorrect retry logic. A properly chosen and configured storage layer solves this problem once and for all. The wrong choice can cost teams months of rewriting code and up to 70% of time spent on synchronization. Our experience — 10+ years in mobile development, over 50 projects with offline storage — confirms: the storage choice determines 80% of future performance and synchronization issues.
In practice, storage selection is driven by two factors: data type and synchronization requirements, not library popularity.
Room (Android) — a wrapper over SQLite with compile-time verification of SQL queries. If a query is invalid, the build fails — better than a SQLiteException at runtime. Room integrates well with Kotlin Flow and LiveData, making reactive UI updates straightforward. The main challenge is schema migrations. @Database(version = N, exportSchema = true) with migration files in assets/databases/ is mandatory; otherwise, fallbackToDestructiveMigration() will simply delete the user's data on app update.
Core Data (iOS) — not a database, but an object graph management framework over SQLite (or XML, or in-memory). NSPersistentContainer with viewContext for reading on the main thread and newBackgroundContext() for writing is the basic setup. The trouble begins when a developer calls save() on viewContext from a background thread: EXC_BAD_ACCESS at a random moment, happens once a week, with almost nothing useful in the crash log. You must use performAndWait or perform for each context strictly on its own thread. Apple Core Data Programming Guide recommends this approach.
Realm wins where you need speed with large object sets and built-in reactivity through Results + observe(). Realm stores objects directly without ORM mapping, so reads require no deserialization. According to our measurements, Realm processes reads 2–3 times faster than Core Data for volumes over 10,000 objects. On Flutter, the Realm SDK (ex-MongoDB Realm) supports Device Sync — but that's a managed service with separate infrastructure.
Hive and Isar are Flutter-specific solutions. Hive is a key-value store, fast, simple, suitable for settings and caches. Isar is a full document-oriented database with indexes, written in Rust, compiled to native code. For Flutter apps with offline functionality, Isar is now preferred: built-in query builder with type-safe filters, transactions, watchObject/watchQuery for reactivity.
| Platform |
Solution |
Reactivity |
Synchronization |
| Android |
Room + Flow |
LiveData/Flow |
WorkManager |
| iOS |
Core Data |
NSFetchedResultsController |
CloudKit |
| Flutter |
Isar |
Streams |
Custom / Realm Sync |
| Cross-platform |
Realm |
RealmResults.observe |
Device Sync |
| Flutter (simple) |
Hive |
ValueListenable |
None |
Contact us for a free audit of your current storage and optimization recommendations — this will save you hundreds of development hours and up to 60% of server request traffic.
Why Is Offline Synchronization the Hardest Part?
Local storage itself is not complicated. The complexity lies in synchronizing with the server in the presence of conflicts.
The most common pattern is optimistic updates with rollback. The user edits a record, the UI reflects the change instantly, a background request goes to the server. If the server returns an error, we roll back the local state. Sounds simple. In practice: if the user has left the screen and returned before the rollback (which may take 3 seconds), the UX is broken. You need an explicit operation queue with states (PENDING, SYNCED, FAILED) in a separate table.
On Android, for background synchronization we use WorkManager with Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED). Don't forget setInputMerger(ArrayCreatingInputMerger::class) when batching tasks — otherwise, concurrent runs will overwrite data. A typical operation queue implementation:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pendingOps = syncDao.getPendingOperations()
for (op in pendingOps) {
try {
apiClient.send(op.payload)
syncDao.markSynced(op.id)
} catch (e: Exception) {
syncDao.markFailed(op.id, e.message)
return Result.retry()
}
}
return Result.success()
}
}
On iOS, the equivalent is BGTaskScheduler with BGProcessingTaskRequest. iOS limitations on background execution time (~30 seconds for refresh tasks) mean that synchronization must be incremental: not "sync everything," but "sync the next N records, save the cursor."
Conflicts in multi-device scenarios are resolved with one of three approaches:
- Last-write-wins based on
updated_at (simplest, loses data on concurrent edits)
- Server-wins (client always accepts server version)
- Three-way merge (complex, requires a common ancestor — suitable for documents)
For most B2C apps, last-write-wins with a user-level time vector is sufficient, but for collaborative editing, a CRDTs approach is needed — then look at Automerge or Yjs with mobile bindings.
How We Build the Storage Layer
The repository pattern is not optional — it's mandatory. UserRepository doesn't know where the data comes from: Room, Realm, or network. The ViewModel calls repository.getUser(id), gets a Flow/Stream, and displays data. Caching logic resides inside the repository.
For Flutter, a typical architecture: Isar for persistence, Riverpod for state management, ConnectivityPlus for network status, and a custom SyncService with an operation queue. Riverpod's AsyncNotifier conveniently covers the logic of "show cache, update from network, show new data." Example repository with caching:
class UserRepository {
final Isar isar;
final ApiClient api;
Future<User> getUser(String id) async {
// try from local storage first
final cached = await isar.user.where().idEqualTo(id).findFirst();
if (cached != null) return cached;
// otherwise from network
final remote = await api.fetchUser(id);
// save locally
await isar.writeTxn(() => isar.user.put(remote));
return remote;
}
}
Another important topic is encryption. If the app stores medical data, payment cards, or corporate documents, SQLCipher (Android) and NSFileProtection (iOS) are not optional. Realm supports encryption natively via a 64-byte key that must be stored in Keychain/Keystore, not in SharedPreferences. Skimping on security can lead to data leaks with serious consequences.
What the Work Includes
We guarantee a transparent process and document each stage:
| Stage |
Result |
| Requirements audit |
Document analyzing data types, volumes, synchronization scenarios |
| Schema design |
ER diagram, migration files, conflict resolution plan |
| Repository layer development |
Code with unit tests (in-memory DB + network mocks) |
| Synchronization integration |
Operation queue, error handling, fallback logic |
| Profiling and optimization |
Report from Android Profiler / Core Data SQLDebug, recommendations |
| Deployment and documentation |
Deployment instructions, API description, repository access |
Want to avoid common mistakes when designing storage? Contact us — we'll help design a reliable local storage from scratch or improve an existing one.
Stages of Work
We start with a requirements audit: what data, what volume, is synchronization needed, are conflicts possible. At this stage, it becomes clear whether Core Data or an SQLite-based solution is needed, whether Realm Sync is required or simple REST polling will suffice.
Next, we design the schema with migrations in mind. Schemas change in any project — the question is not "will there be migrations," but "how painful will they be." We export the schema as JSON, store it in the repository, and write tests for each version's migration.
Development includes unit test coverage for the repository layer: network layer mocks, a real in-memory database for query testing. Before release, we profile queries using Android Profiler (Database Inspector tab) or Core Data debug flags (-com.apple.CoreData.SQLDebug 1).
The implementation timeline for a storage layer with basic offline synchronization ranges from 2 to 6 weeks, depending on schema complexity and conflict resolution requirements. Contact us to get a consultation on choosing the optimal stack and migrations.