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:

  1. Fake the progress with a Timer.periodic that increments a value until the response arrives
  2. 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 forget

The 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 http package doesn't support upload progress. Switch to Dio.
  • onSendProgress gives 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

Related Articles

How to Prevent Duplicate API Requests in Flutter (Debounce, Idempotency, and Queue Patterns)

Users double-tap buttons, retry on slow networks, and pull-to-refresh while a request is already in flight. Here's how to prevent duplicate submissions, wasted API calls, and corrupted data in Flutter apps.

7 min read

Stop Overengineering State Management in Flutter (When setState Is Enough)

Not every Flutter screen needs Riverpod, BLoC, or Redux. Here's a practical framework for choosing the right state management approach based on actual complexity — with real examples of when setState, ValueNotifier, and providers each make sense.

7 min read

Building Offline Mode in Flutter That Actually Works (Sync Queue Pattern)

Most Flutter offline tutorials only cache GET requests. Here's how to build a real offline mode that queues mutations, syncs when connectivity returns, and handles conflicts — with a production-tested sync queue architecture.

8 min read