Modern mobile applications often require reliable background file uploads — whether you’re sending media, syncing offline data, or processing batch operations. The challenge is not just uploading files, but doing so in a way that is robust, resumable, observable, and UI-friendly.
In this article, we’ll break down the production-grade architecture used in the Naberise app for handling background uploads in Flutter. The goal is to explain how this system works in practice and how it supports Naberise’s specific post upload workflows.
We want to build a system that:
- Handles multiple upload jobs sequentially
- Tracks progress at both item and job levels
- Survives app restarts (background resilience)
- Provides reactive UI updates
- Avoids race conditions and inconsistent states
Architecture:
At a high level, the system is composed of three layers:
- Data Models → Represent upload units and their state
- Upload Manager → Orchestrates queueing, execution, and updates
- UI Layer → Reacts to state changes and displays progress
This architecture is powered by the background_downloader package, which provides a cross-platform abstraction for handling long-running uploads and downloads.
At its core, this package allows you to:
- Define upload/download tasks and enqueue them
- Receive progress and status updates via callbacks or listeners
- Run tasks even when the app is in the background
- Persist task state in a local database
- Group tasks and monitor them centrally
Under the hood, it relies on native OS capabilities like URLSession on iOS and WorkManager/DownloadWorker on Android, which enables tasks to continue even when the app is not active.
1. Data Modeling: Thinking in Units
Instead of tightly coupling uploads to business logic, we define generic models:
a) UploadItem
class UploadItem {
final String? filePath;
final String? taskId;
final double progress;
final UploadStatus status;
}
This abstraction allows you to represent:
- Files
- Chunks
- Any transferable payload
b) UploadJob
A job is a collection of items that should be processed together:
class UploadJob {
final List<UploadItem> items;
final UploadStatus status;
double get progress => items.fold(0, (sum, i) => sum + i.progress) / items.length;
}
Group related uploads into a single job to:
- Simplify progress tracking
- Reduce UI complexity
- Avoid concurrency issues
c) UploadState
class UploadState {
final List<UploadJob> jobs;
final int startedJobs;
final int totalJobs;
}
This acts as the single source of truth for the UI.
2. Upload Manager: The Orchestrator
The Upload Manager is responsible for:
- Queueing jobs
- Executing uploads sequentially
- Listening to background events
- Updating state reactively
a) Queue-Based Execution
final List<UploadJob> _queue = [];
bool _isUploading = false;
Instead of running everything in parallel, we process jobs one at a time:
Because a user can only share one post at a time.
void enqueueJob(UploadJob job) {
_queue.add(job);
_startNextJob();
}
3. Batch Upload Strategy
Instead of uploading files individually, we use a single multi-part request per job:
final task = MultiUploadTask(
files: job.items.map((i) => i.filePath).toList(),
);
This maps perfectly to the capabilities of the background execution layer, which supports both single-file and multi-part uploads.
a) Why MultiUploadTask?
- One task → One lifecycle → One notification
- Eliminates synchronization issues between items
- Simplifies completion logic
4. Event-Driven State Updates
The system listens to two main event streams:
a) Progress Updates
void handleProgress(String taskId, double progress) {
updateItemProgress(taskId, progress);
}
b) Status Updates
void handleStatus(TaskStatus status) {
if (status == complete) markCompleted();
if (status == failed) markFailed();
}
The underlying library emits these updates either:
- Per task (callback-based)
- Or centrally via registered listeners
This enables a fully reactive architecture.
5. Observing State
The UI simply reacts to the current state:
final currentJob = state.jobs.firstWhere(
(j) => j.status == UploadStatus.uploading,
);
Progress is derived, not stored:
LinearProgressIndicator(value: currentJob.progress);
6. Background Resilience
One of the most critical features: restoring uploads after app restart.
final records = await downloader.database.allRecords();
The dependency provides a persistent task database, which allows you to reconstruct state after app relaunch.
We reconstruct jobs from persisted tasks:
for (final record in records) {
restoreJobFrom(record);
}
7. Job Lifecycle
Each job goes through:
pending → uploading → completed / failed
Completion logic:
if (job.isFinished) {
finishJob(job);
}
And then:
- Start next job
- Or notify completion of all jobs
This architecture is not limited to file uploads. You can reuse the same principles for:
- Data synchronization
- Background processing pipelines
- Offline-first applications
If you’re building a Flutter app that deals with background operations, investing in this kind of structure early will save you from countless edge-case bugs later.
Thanks for reading.
Designing a Background Upload System in Flutter for the Naberise
