← Back to Blog Our Take

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

September 28, 2024
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:

  1. File Size: Modern phones record 4K video at 100MB+ per minute
  2. Memory Constraints: React Native’s JavaScript thread can’t handle large files efficiently
  3. Network Reliability: Mobile connections drop, throttle, and switch between WiFi and cellular
  4. Background Restrictions: iOS and Android aggressively limit background processing
  5. 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:

  1. Backend Orchestration: Let the server decide the best upload strategy
  2. Chunked Uploads: Break large files into manageable pieces
  3. Resumability: Always be able to continue from where you left off
  4. Network Awareness: Adapt to changing network conditions
  5. 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