Fixing Video Uploads in React Native: A Backend-Driven Approach to Compression, Background Handling, and Reliability

Video uploads in mobile apps are deceptively complex. What seems like a simple feature, letting users upload videos, quickly becomes a maze of device-specific quirks, network interruptions, and memory constraints. After battling these challenges across multiple React Native projects, we’ve developed a backend-driven approach that finally makes video uploads reliable.
The Problem Landscape
Before diving into solutions, let’s understand why video uploads are particularly challenging in React Native:
- File Size: Modern phones record 4K video at 100MB+ per minute
- Memory Constraints: React Native’s JavaScript thread can’t handle large files efficiently
- Network Reliability: Mobile connections drop, throttle, and switch between WiFi and cellular
- Background Restrictions: iOS and Android aggressively limit background processing
- Cross-Platform Differences: Each platform handles media differently
Traditional Approach (And Why It Fails)
Most React Native video upload implementations follow this pattern:
// DON'T DO THIS - Traditional problematic approach
const uploadVideo = async (videoUri) => {
try {
// This loads entire video into memory!
const videoData = await RNFS.readFile(videoUri, 'base64');
// This fails on large files or slow connections
const response = await fetch('https://api.example.com/upload', {
method: 'POST',
body: JSON.stringify({ video: videoData }),
headers: { 'Content-Type': 'application/json' }
});
return response.json();
} catch (error) {
// No recovery mechanism
console.error('Upload failed:', error);
}
};
This approach fails because it:
- Loads entire videos into memory (crash on large files)
- Can’t resume failed uploads
- Stops when the app backgrounds
- Provides no progress feedback
- Offers no compression options
The Backend-Driven Solution
Our approach flips the script: instead of the client managing the entire upload, we orchestrate the process from the backend while keeping the client lightweight.
Architecture Overview
// Backend-driven upload flow
interface VideoUploadFlow {
1. Client requests upload session
2. Backend returns upload strategy (chunked, direct, compressed)
3. Client prepares video based on strategy
4. Client uploads using resumable protocol
5. Backend processes and confirms completion
}
Step 1: Smart Upload Session Creation
The backend analyzes device capabilities and returns an optimized upload strategy:
// React Native - Request upload session
const createUploadSession = async (videoMetadata) => {
const deviceInfo = {
platform: Platform.OS,
version: Platform.Version,
memory: DeviceInfo.getMaxMemory(),
connection: await NetInfo.fetch()
};
const response = await api.post('/video/upload-session', {
metadata: videoMetadata,
device: deviceInfo
});
return response.data; // Contains upload strategy
};
// Backend - Determine optimal strategy
app.post('/video/upload-session', async (req, res) => {
const { metadata, device } = req.body;
const strategy = determineUploadStrategy({
fileSize: metadata.size,
duration: metadata.duration,
deviceMemory: device.memory,
networkType: device.connection.type
});
if (strategy.type === 'chunked') {
const session = await createChunkedUploadSession({
fileSize: metadata.size,
chunkSize: strategy.chunkSize,
expires: Date.now() + 24 * 60 * 60 * 1000 // 24 hours
});
res.json({
sessionId: session.id,
uploadUrl: session.uploadUrl,
strategy: strategy,
compression: strategy.compressionSettings
});
}
});
Step 2: Intelligent Video Compression
Compression happens on-device but with backend-provided parameters:
// React Native - Compress based on backend strategy
import { Video } from 'react-native-compressor';
const compressVideo = async (videoUri, compressionSettings) => {
const {
bitrate,
resolution,
frameRate,
shouldMaintainAspectRatio
} = compressionSettings;
try {
const compressedUri = await Video.compress(
videoUri,
{
compressionMethod: 'auto',
bitrate: bitrate || 'medium',
resolution: resolution || 720,
frameRate: frameRate || 30,
minimumFileSizeForCompress: 0,
},
(progress) => {
// Update UI with compression progress
updateProgress('compression', progress);
}
);
return compressedUri;
} catch (error) {
// Fallback to original if compression fails
console.warn('Compression failed, using original:', error);
return videoUri;
}
};
Step 3: Resumable Chunked Upload
The core of reliability is chunked, resumable uploads:
// React Native - Chunked upload implementation
import RNFS from 'react-native-fs';
class ChunkedUploader {
constructor(sessionData) {
this.sessionId = sessionData.sessionId;
this.uploadUrl = sessionData.uploadUrl;
this.chunkSize = sessionData.strategy.chunkSize;
this.totalChunks = Math.ceil(sessionData.fileSize / this.chunkSize);
this.uploadedChunks = new Set();
}
async uploadVideo(videoUri, onProgress) {
// Get already uploaded chunks from backend
await this.syncUploadedChunks();
const fileSize = await RNFS.stat(videoUri).then(stat => stat.size);
for (let chunkIndex = 0; chunkIndex < this.totalChunks; chunkIndex++) {
if (this.uploadedChunks.has(chunkIndex)) {
continue; // Skip already uploaded chunks
}
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, fileSize);
await this.uploadChunk(videoUri, chunkIndex, start, end);
const progress = (chunkIndex + 1) / this.totalChunks;
onProgress(progress);
}
return this.completeUpload();
}
async uploadChunk(videoUri, chunkIndex, start, end) {
const chunkData = await RNFS.read(videoUri, end - start, start, 'base64');
const response = await fetch(`${this.uploadUrl}/chunk`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'X-Session-ID': this.sessionId,
'X-Chunk-Index': chunkIndex.toString(),
'X-Chunk-Start': start.toString(),
'X-Chunk-End': end.toString(),
},
body: chunkData
});
if (!response.ok) {
throw new Error(`Chunk upload failed: ${response.status}`);
}
this.uploadedChunks.add(chunkIndex);
}
async syncUploadedChunks() {
const response = await fetch(`${this.uploadUrl}/status`, {
headers: { 'X-Session-ID': this.sessionId }
});
const status = await response.json();
this.uploadedChunks = new Set(status.uploadedChunks);
}
async completeUpload() {
const response = await fetch(`${this.uploadUrl}/complete`, {
method: 'POST',
headers: { 'X-Session-ID': this.sessionId }
});
return response.json();
}
}
Step 4: Background Upload Support
React Native’s background limitations require careful handling:
// iOS - Background upload using NSURLSession
import { NativeModules } from 'react-native';
const { BackgroundUploader } = NativeModules;
// Native iOS Module (Objective-C/Swift)
@implementation BackgroundUploader
- (void)createBackgroundSession:(NSString *)sessionId {
NSURLSessionConfiguration *config = [NSURLSessionConfiguration
backgroundSessionConfigurationWithIdentifier:sessionId];
config.discretionary = NO;
config.sessionSendsLaunchEvents = YES;
self.backgroundSession = [NSURLSession
sessionWithConfiguration:config
delegate:self
delegateQueue:nil];
}
- (void)uploadVideoInBackground:(NSString *)videoPath
sessionId:(NSString *)sessionId
uploadUrl:(NSString *)uploadUrl {
NSURL *fileURL = [NSURL fileURLWithPath:videoPath];
NSMutableURLRequest *request = [NSMutableURLRequest
requestWithURL:[NSURL URLWithString:uploadUrl]];
[request setHTTPMethod:@"PUT"];
[request setValue:sessionId forHTTPHeaderField:@"X-Session-ID"];
NSURLSessionUploadTask *uploadTask = [self.backgroundSession
uploadTaskWithRequest:request
fromFile:fileURL];
[uploadTask resume];
}
@end
// React Native integration
const startBackgroundUpload = async (videoUri, sessionData) => {
if (Platform.OS === 'ios') {
return BackgroundUploader.uploadVideoInBackground(
videoUri,
sessionData.sessionId,
sessionData.uploadUrl
);
} else {
// Android: Use WorkManager
return AndroidBackgroundUploader.enqueueUpload({
videoUri,
sessionData,
constraints: {
requiresNetwork: true,
requiresBatteryNotLow: true
}
});
}
};
Step 5: Network-Aware Upload Management
Adapt upload behavior based on network conditions:
import NetInfo from '@react-native-community/netinfo';
class NetworkAwareUploader {
constructor() {
this.netInfo = null;
this.setupNetworkListener();
}
setupNetworkListener() {
NetInfo.addEventListener(state => {
this.netInfo = state;
this.adjustUploadStrategy(state);
});
}
adjustUploadStrategy(networkState) {
if (!this.currentUpload) return;
if (networkState.type === 'cellular') {
// Reduce chunk size on cellular
this.currentUpload.chunkSize = 512 * 1024; // 512KB chunks
if (networkState.details.cellularGeneration === '2g') {
// Pause on 2G
this.currentUpload.pause();
}
} else if (networkState.type === 'wifi') {
// Increase chunk size on WiFi
this.currentUpload.chunkSize = 2 * 1024 * 1024; // 2MB chunks
if (this.currentUpload.isPaused) {
this.currentUpload.resume();
}
} else if (networkState.type === 'none') {
// No connection - pause and wait
this.currentUpload.pause();
}
}
async uploadWithNetworkAwareness(videoUri, sessionData) {
const uploader = new ChunkedUploader(sessionData);
this.currentUpload = uploader;
// Check if we should wait for WiFi
if (await this.shouldWaitForWiFi(videoUri)) {
await this.waitForWiFi();
}
return uploader.uploadVideo(videoUri, (progress) => {
this.onProgress(progress);
});
}
async shouldWaitForWiFi(videoUri) {
const stats = await RNFS.stat(videoUri);
const settings = await AsyncStorage.getItem('uploadSettings');
const { wifiOnlyThreshold } = JSON.parse(settings || '{}');
return stats.size > (wifiOnlyThreshold || 50 * 1024 * 1024); // 50MB default
}
}
Backend Processing Pipeline
Once chunks are uploaded, the backend handles processing:
// Backend - Video processing pipeline
class VideoProcessor {
async processUploadedVideo(sessionId) {
const session = await this.getSession(sessionId);
// 1. Reassemble chunks
const videoPath = await this.reassembleChunks(session);
// 2. Validate video integrity
const validation = await this.validateVideo(videoPath);
if (!validation.isValid) {
throw new Error(`Video validation failed: ${validation.error}`);
}
// 3. Generate thumbnails
const thumbnails = await this.generateThumbnails(videoPath);
// 4. Transcode for different qualities
const transcoded = await this.transcodeVideo(videoPath, {
qualities: ['1080p', '720p', '480p'],
format: 'mp4',
codec: 'h264'
});
// 5. Upload to CDN
const cdnUrls = await this.uploadToCDN(transcoded);
// 6. Update database
await this.updateVideoRecord(sessionId, {
status: 'completed',
urls: cdnUrls,
thumbnails: thumbnails,
metadata: validation.metadata
});
// 7. Notify client
await this.notifyClient(session.userId, {
type: 'upload_complete',
videoId: session.videoId
});
}
async reassembleChunks(session) {
const chunks = await this.storage.listChunks(session.id);
const outputPath = `/tmp/${session.id}.mp4`;
// Sort chunks and concatenate
chunks.sort((a, b) => a.index - b.index);
const writeStream = fs.createWriteStream(outputPath);
for (const chunk of chunks) {
const chunkData = await this.storage.getChunk(chunk.path);
writeStream.write(chunkData);
}
writeStream.end();
return outputPath;
}
}
Error Recovery and Retry Logic
Robust error handling is crucial:
// React Native - Retry mechanism
class UploadRetryManager {
constructor(maxRetries = 3, backoffMultiplier = 2) {
this.maxRetries = maxRetries;
this.backoffMultiplier = backoffMultiplier;
this.retryCount = 0;
this.baseDelay = 1000; // 1 second
}
async uploadWithRetry(uploadFn, onError) {
while (this.retryCount < this.maxRetries) {
try {
return await uploadFn();
} catch (error) {
this.retryCount++;
if (this.shouldRetry(error)) {
const delay = this.calculateBackoff();
await this.delay(delay);
onError({
error,
retryCount: this.retryCount,
nextRetryIn: delay
});
} else {
throw error; // Non-retryable error
}
}
}
throw new Error('Max retries exceeded');
}
shouldRetry(error) {
// Retry on network errors and 5xx server errors
return (
error.code === 'NETWORK_ERROR' ||
(error.status >= 500 && error.status < 600) ||
error.code === 'TIMEOUT'
);
}
calculateBackoff() {
return this.baseDelay * Math.pow(this.backoffMultiplier, this.retryCount - 1);
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Performance Optimizations
1. Memory Management
// Efficient chunk reading without loading entire file
async function* readFileInChunks(filePath, chunkSize) {
const fileHandle = await RNFS.openFile(filePath, 'r');
let position = 0;
try {
while (true) {
const chunk = await RNFS.read(
filePath,
chunkSize,
position,
'base64'
);
if (!chunk) break;
yield { data: chunk, position, size: chunk.length };
position += chunk.length;
}
} finally {
await RNFS.closeFile(fileHandle);
}
}
2. Progress Tracking
// Granular progress tracking
class UploadProgressTracker {
constructor(totalSize) {
this.totalSize = totalSize;
this.uploadedSize = 0;
this.startTime = Date.now();
this.samples = [];
}
updateProgress(bytesUploaded) {
this.uploadedSize += bytesUploaded;
const now = Date.now();
this.samples.push({
timestamp: now,
bytes: bytesUploaded
});
// Keep only last 10 samples for speed calculation
if (this.samples.length > 10) {
this.samples.shift();
}
return {
percentage: (this.uploadedSize / this.totalSize) * 100,
bytesUploaded: this.uploadedSize,
bytesRemaining: this.totalSize - this.uploadedSize,
speed: this.calculateSpeed(),
estimatedTimeRemaining: this.estimateTimeRemaining()
};
}
calculateSpeed() {
if (this.samples.length < 2) return 0;
const duration = this.samples[this.samples.length - 1].timestamp -
this.samples[0].timestamp;
const bytes = this.samples.reduce((sum, s) => sum + s.bytes, 0);
return bytes / (duration / 1000); // bytes per second
}
estimateTimeRemaining() {
const speed = this.calculateSpeed();
if (speed === 0) return Infinity;
const remainingBytes = this.totalSize - this.uploadedSize;
return remainingBytes / speed; // seconds
}
}
Conclusion
Building a reliable video upload system in React Native requires thinking beyond the traditional client-side approach. By orchestrating uploads from the backend, implementing resumable chunked uploads, and building in robust error recovery, we can create an upload experience that works reliably across all devices and network conditions.
The key insights from our approach:
- Backend Orchestration: Let the server decide the best upload strategy
- Chunked Uploads: Break large files into manageable pieces
- Resumability: Always be able to continue from where you left off
- Network Awareness: Adapt to changing network conditions
- Background Support: Use platform-specific APIs for true background uploads
This architecture has proven reliable across millions of video uploads, handling everything from spotty cellular connections to multi-gigabyte files. While the initial implementation requires more effort than a simple upload, the improved user experience and reduced support burden make it worthwhile for any app serious about video content.
Ready to Build Something Amazing?
Let's discuss how Aviron Labs can help bring your ideas to life with custom software solutions.
Get in Touch