How to Show Real File Upload Progress in Flutter (Not Fake Progress Bars)
You've built the file picker. You've wired up the API. You even added a cute little LinearProgressIndicator. But here's the problem — your progress bar jumps from 0% to 100% instantly, or worse, it creeps along at a fake pace you hardcoded with a timer.
Sound familiar? You're not alone. This is one of the most silently frustrating problems in Flutter development, and most tutorials get it completely wrong.
Why This Happens
The root cause is simple: if you're using http.MultipartRequest from Dart's built-in http package, you have no access to upload progress. The request fires, the bytes go out, and you get a response. That's it. No progress callback. No stream events. Nothing.
So developers do one of two things:
- Fake the progress with a
Timer.periodicthat increments a value until the response arrives - Show only an indeterminate spinner and hope the user is patient
Both are terrible for UX, especially when uploading large files like videos or documents on slow mobile networks.
Common Mistakes Developers Make
Mistake 1: Using a Timer to Simulate Progress
// ❌ Fake progress — never do this
Timer.periodic(Duration(milliseconds: 100), (timer) {
setState(() {
progress += 0.01;
if (progress >= 0.95) timer.cancel(); // "close enough"
});
});This creates a progress bar that has no relationship with actual upload speed. On a slow connection, it fills up and then stalls at 95%. On a fast connection, the upload finishes before the animation catches up. Users learn to distrust your UI.
Mistake 2: Using http.MultipartRequest and Expecting Events
// ❌ No progress callback exists here
var request = http.MultipartRequest('POST', uri);
request.files.add(await http.MultipartFile.fromPath('file', filePath));
var response = await request.send(); // Fire and forgetThe http package doesn't expose byte-level streaming during upload. The send() method buffers the entire request and sends it in one shot.
Mistake 3: Using a StreamController Without Proper Byte Counting
Some developers try wrapping the file stream in a StreamController, but forget to actually count bytes sent vs. total bytes. The result? A progress value that jumps randomly or never reaches 100%.
The Real Solution: Dio + Stream Transformers
Dio is the only mainstream HTTP package in Flutter that provides a proper onSendProgress callback. Here's how to use it correctly.
Step 1: Set Up Dio
import 'package:dio/dio.dart';
final dio = Dio(BaseOptions(
connectTimeout: Duration(seconds: 30),
receiveTimeout: Duration(seconds: 60),
sendTimeout: Duration(seconds: 120), // Important for large files
));The sendTimeout is critical. Large file uploads can take minutes on mobile networks. Don't let the default timeout kill your request at 15 seconds.
Step 2: Build the Upload Method
Future<String?> uploadFile({
required String filePath,
required String uploadUrl,
required void Function(double) onProgress,
}) async {
try {
final fileName = filePath.split('/').last;
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
filePath,
filename: fileName,
),
});
final response = await dio.post(
uploadUrl,
data: formData,
onSendProgress: (int sent, int total) {
if (total > 0) {
onProgress(sent / total); // 0.0 to 1.0
}
},
);
return response.data['url']; // or whatever your API returns
} on DioException catch (e) {
if (e.type == DioExceptionType.sendTimeout) {
throw Exception('Upload timed out. Check your connection.');
}
rethrow;
}
}The magic is in onSendProgress. Dio calls this callback repeatedly as bytes flow out. The sent parameter is the cumulative bytes uploaded so far, and total is the full payload size. This gives you real, byte-accurate progress.
Step 3: Wire It Up to the UI
class FileUploadWidget extends StatefulWidget {
@override
State<FileUploadWidget> createState() => _FileUploadWidgetState();
}
class _FileUploadWidgetState extends State<FileUploadWidget> {
double _progress = 0.0;
bool _isUploading = false;
String? _error;
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles();
if (result == null) return;
final filePath = result.files.single.path!;
setState(() {
_isUploading = true;
_progress = 0.0;
_error = null;
});
try {
await uploadFile(
filePath: filePath,
uploadUrl: 'https://api.example.com/upload',
onProgress: (value) {
setState(() => _progress = value);
},
);
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _isUploading = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_isUploading) ...[
LinearProgressIndicator(value: _progress),
SizedBox(height: 8),
Text('${(_progress * 100).toStringAsFixed(1)}%'),
],
if (_error != null)
Text(_error!, style: TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: _isUploading ? null : _pickAndUpload,
child: Text(_isUploading ? 'Uploading...' : 'Select File'),
),
],
);
}
}Step 4: Handle Edge Cases (Production Checklist)
Real apps need to handle what tutorials skip:
Cancellation — Let users cancel long uploads:
final cancelToken = CancelToken();
// In the upload call:
await dio.post(url, data: formData, cancelToken: cancelToken);
// On user cancel:
cancelToken.cancel('User cancelled upload');Retry on failure — Network drops are common on mobile:
int retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
await uploadFile(...);
break;
} catch (e) {
retryCount++;
if (retryCount >= maxRetries) rethrow;
await Future.delayed(Duration(seconds: 2 * retryCount));
}
}File size validation — Don't let users attempt a 500MB upload that your server will reject:
final fileSize = File(filePath).lengthSync();
const maxSize = 50 * 1024 * 1024; // 50 MB
if (fileSize > maxSize) {
throw Exception('File too large. Maximum size is 50 MB.');
}Key Takeaways
- Never fake progress. Users can tell. It destroys trust in your app.
- Dart's
httppackage doesn't support upload progress. Switch to Dio. onSendProgressgives you real, byte-level tracking. It's the only reliable way.- Always handle timeouts, cancellation, and retries. Mobile networks are unreliable.
- Validate file size client-side before starting the upload.
What About Download Progress?
Dio has onReceiveProgress too — same concept in reverse. If you're downloading files and need a progress bar, the pattern is identical.
These are the patterns I use in every production Flutter app. File uploads seem simple until you deploy to real users on 3G networks. Getting the progress bar right is the difference between an app that feels polished and one that feels broken.
Find me at: itszain.dev