# Upload videos to Mux from React Native

Upload videos to Mux from a React Native device, or create assets from a URL. Use this guide for user-recorded video or for AI-generated content workflows.

There are two primary ways to get videos into Mux from a React Native application:

1. **Direct upload from device** - User records or selects a video, which is uploaded directly from their mobile device
2. **Upload from URL** - Your backend creates a Mux asset from a video URL (ideal for AI-generated content)

This guide covers both approaches and helps you choose the right one for your use case.

## Choosing an upload method

| Method | Use Case | React Native Role | Backend Required | User Experience |
|--------|----------|-------------------|------------------|-----------------|
| **Direct Upload** | User-generated content (camera, library) | High - handles file upload | Yes - generates upload URL | Shows upload progress |
| **Upload from URL** | AI-generated videos, pre-hosted content | Low - just displays result | Yes - creates asset | Background process |

### When to use direct upload

Use direct upload when:

* Users record videos with their device camera
* Users select videos from their photo library
* You need to show upload progress to the user
* The video file is on the user's device

### When to use upload from URL

Use upload from URL when:

* Videos are generated by AI services (Runway, Pika, Fal.ai, etc.)
* Videos are already hosted elsewhere (S3, GCS, etc.)
* You want to ingest videos without user intervention
* The video generation happens on your backend

<Callout type="info">
  For apps that use on-demand generative AI video, **upload from URL** is the right choice since videos are generated by AI services and returned as URLs.
</Callout>

***

## Direct upload from mobile device

Direct uploads allow users to upload videos directly from their React Native app to Mux without the file touching your backend servers.

### Architecture

```
User Device → Your Backend (generate upload URL) → Mux
                ↓
            Upload URL returned
                ↓
User Device → Mux (upload file directly)
                ↓
            Mux processes video
                ↓
            Webhook → Your Backend (asset ready)
```

### Step 1: Generate a signed upload URL (backend)

Your backend must generate a signed upload URL using the Mux API:

```javascript
// Backend: Node.js + Mux SDK
import Mux from '@mux/mux-node';

const mux = new Mux({
  tokenId: process.env.MUX_TOKEN_ID,
  tokenSecret: process.env.MUX_TOKEN_SECRET,
});

// API endpoint: POST /api/generate-upload-url
export async function generateUploadUrl(req, res) {
  try {
    const upload = await mux.video.uploads.create({
      cors_origin: '*', // Or specify your app's origin
      new_asset_settings: {
        playback_policies: ['public'],
        video_quality: "basic"
      },
    });

    res.json({
      uploadUrl: upload.url,
      uploadId: upload.id,
    });
  } catch (error) {
    console.error('Failed to create upload URL:', error);
    res.status(500).json({ error: 'Failed to generate upload URL' });
  }
}
```

<Callout type="warning">
  Never expose your Mux API credentials in your React Native app. Always generate upload URLs from your backend.
</Callout>

### Step 2: Record or select video (React Native)

Use Expo Camera or ImagePicker to get a video file:

```tsx
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';

export function useVideoPicker() {
  const [videoUri, setVideoUri] = useState<string | null>(null);

  const pickVideo = async () => {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Videos,
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      setVideoUri(result.assets[0].uri);
    }
  };

  const recordVideo = async () => {
    const result = await ImagePicker.launchCameraAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Videos,
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      setVideoUri(result.assets[0].uri);
    }
  };

  return { videoUri, pickVideo, recordVideo };
}
```

### Step 3: Upload to Mux (React Native)

Upload the video file using Expo FileSystem:

```tsx
import * as FileSystem from 'expo-file-system';
import { useState } from 'react';

interface UploadResult {
  uploadId: string;
  assetId?: string;
}

export function useVideoUpload() {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);

  const uploadVideo = async (videoUri: string): Promise<UploadResult | null> => {
    setUploading(true);
    setUploadProgress(0);
    setError(null);

    try {
      // Step 1: Get upload URL from your backend
      const response = await fetch('https://your-api.com/generate-upload-url', {
        method: 'POST',
      });
      const { uploadUrl, uploadId } = await response.json();

      // Step 2: Upload video file to Mux with progress tracking
      const uploadTask = FileSystem.createUploadTask(
        uploadUrl,
        videoUri,
        {
          httpMethod: 'PUT',
          uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
        },
        (uploadProgress) => {
          const progress = uploadProgress.totalBytesSent / uploadProgress.totalBytesExpectedToSend;
          setUploadProgress(Math.round(progress * 100));
        }
      );

      const uploadResponse = await uploadTask.uploadAsync();

      if (!uploadResponse || uploadResponse.status !== 200) {
        throw new Error('Upload failed');
      }

      setUploading(false);

      return { uploadId };
    } catch (err) {
      console.error('Upload error:', err);
      setError('Failed to upload video');
      setUploading(false);
      setUploadProgress(0);
      return null;
    }
  };

  return { uploadVideo, uploading, uploadProgress, error };
}
```

### Step 4: Handle upload completion

The video asset won't be ready immediately after upload. You'll need to:

1. Listen for the `video.asset.ready` webhook on your backend
2. Update your database with the playback ID
3. Notify the React Native app (via polling, realtime DB, or push notification)

See the [async processing guide](/docs/frameworks/react-native-async-processing) for implementation details.

### Complete upload example

```tsx
import React, { useState } from 'react';
import {
  View,
  TouchableOpacity,
  Text,
  ActivityIndicator,
  StyleSheet,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';

export default function VideoUploader() {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [uploadId, setUploadId] = useState<string | null>(null);

  const selectAndUploadVideo = async () => {
    // Step 1: Select video
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Videos,
      quality: 1,
    });

    if (result.canceled) return;

    const videoUri = result.assets[0].uri;
    setUploading(true);
    setUploadProgress(0);

    try {
      // Step 2: Get upload URL
      const response = await fetch('https://your-api.com/generate-upload-url', {
        method: 'POST',
      });
      const { uploadUrl, uploadId } = await response.json();

      // Step 3: Upload to Mux with progress tracking
      const uploadTask = FileSystem.createUploadTask(
        uploadUrl,
        videoUri,
        {
          httpMethod: 'PUT',
          uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
        },
        (progress) => {
          const percentage = progress.totalBytesSent / progress.totalBytesExpectedToSend;
          setUploadProgress(Math.round(percentage * 100));
        }
      );

      await uploadTask.uploadAsync();

      setUploadId(uploadId);
      setUploading(false);

      // Video is now processing - see async processing guide
      // for how to get notified when it's ready
    } catch (error) {
      console.error('Upload failed:', error);
      setUploading(false);
      setUploadProgress(0);
    }
  };

  return (
    <View style={styles.container}>
      {uploading ? (
        <View style={styles.uploadingContainer}>
          <ActivityIndicator size="large" />
          <Text style={styles.progressText}>Uploading: {uploadProgress}%</Text>
        </View>
      ) : uploadId ? (
        <Text style={styles.text}>
          Video uploaded! Processing...
        </Text>
      ) : (
        <TouchableOpacity style={styles.button} onPress={selectAndUploadVideo}>
          <Text style={styles.buttonText}>Select Video</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 20,
    alignItems: 'center',
  },
  uploadingContainer: {
    alignItems: 'center',
  },
  button: {
    backgroundColor: '#007AFF',
    padding: 15,
    borderRadius: 8,
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
  text: {
    fontSize: 16,
  },
  progressText: {
    marginTop: 10,
    fontSize: 16,
    color: '#666',
  },
});
```

***

## Upload from URL (for AI-generated videos)

This approach is ideal when videos are generated by AI services or already hosted elsewhere. The video never touches the React Native app - your backend handles everything.

### Architecture

```
User submits prompt → Your Backend → AI Service (Fal.ai, Runway, etc.)
                                            ↓
                                      AI returns video URL
                                            ↓
                      Your Backend → Mux (create asset from URL)
                                            ↓
                                     Mux ingests & processes
                                            ↓
                      Webhook → Your Backend (asset ready)
                                            ↓
                      Realtime DB → React Native App
```

### Step 1: Create asset from URL (backend)

Your backend receives the AI-generated video URL and creates a Mux asset:

```javascript
// Backend: Node.js + Mux SDK
import Mux from '@mux/mux-node';

const mux = new Mux({
  tokenId: process.env.MUX_TOKEN_ID,
  tokenSecret: process.env.MUX_TOKEN_SECRET,
});

// API endpoint: POST /api/create-video-from-url
export async function createVideoFromUrl(req, res) {
  const { videoUrl, userId, prompt } = req.body;

  try {
    // Create Mux asset from URL
    const asset = await mux.video.assets.create({
      input: [{ url: videoUrl }],
      playback_policies: ['public'],
      video_quality: "basic"
    });

    // Store in your database
    await db.videos.create({
      id: generateId(),
      userId,
      prompt,
      muxAssetId: asset.id,
      status: 'processing', // Will be updated via webhook
      createdAt: new Date(),
    });

    res.json({
      videoId: video.id,
      assetId: asset.id,
      status: 'processing',
    });
  } catch (error) {
    console.error('Failed to create asset:', error);
    res.status(500).json({ error: 'Failed to create video asset' });
  }
}
```

<Callout type="info">
  The video URL must be publicly accessible. Mux will fetch the video file from that URL to ingest it.
</Callout>

### Step 2: Handle asset ready webhook (backend)

When Mux finishes processing, it sends a webhook to your backend:

```javascript
// Backend: Webhook handler

// check the mux-node-sdk docs for details
// https://github.com/muxinc/mux-node-sdk/blob/master/api.md#webhooks
const mux = new Mux();

// API endpoint: POST /api/webhooks/mux
export async function handleMuxWebhook(req, res) {
  const webhookSecret = process.env.MUX_WEBHOOK_SECRET;
  const signature = req.headers['mux-signature'];

  // Verify webhook signature
  try {
    mux.webhooks.verifySignature(req.body, req.headers, webhookSecret);
  } catch (error) {
    console.error('Invalid webhook signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = req.body;

  // Handle video.asset.ready
  if (event.type === 'video.asset.ready') {
    const { id, playback_ids, duration } = event.data;

    // Update database
    await db.videos.update({
      where: { muxAssetId: id },
      data: {
        status: 'ready',
        playbackId: playback_ids[0].id,
        duration,
      },
    });

    // Video is now ready to play!
    // Your realtime database will notify the React Native app
  }

  // Handle video.asset.errored
  if (event.type === 'video.asset.errored') {
    const { id } = event.data;

    await db.videos.update({
      where: { muxAssetId: id },
      data: {
        status: 'failed',
        error: 'Video processing failed',
      },
    });
  }

  res.json({ received: true });
}
```

<Callout type="warning">
  Always verify webhook signatures to ensure requests are actually from Mux. See the [webhooks guide](/docs/core/listen-for-webhooks) for details.
</Callout>

### Step 3: React Native subscribes to status updates

Your React Native app doesn't handle the upload - it just waits for the video to be ready:

```tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { supabase } from './supabase'; // or Firebase, etc.
import { useVideoPlayer, VideoView } from 'expo-video';

interface VideoGenerationProps {
  videoId: string;
}

export default function VideoGeneration({ videoId }: VideoGenerationProps) {
  const [status, setStatus] = useState<'processing' | 'ready' | 'failed'>('processing');
  const [playbackId, setPlaybackId] = useState<string | null>(null);

  const player = useVideoPlayer(
    playbackId ? `https://stream.mux.com/${playbackId}.m3u8` : null,
    (player) => {
      player.loop = false;
    }
  );

  useEffect(() => {
    // Subscribe to video status changes using Supabase Realtime
    const subscription = supabase
      .channel('video-updates')
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'videos',
          filter: `id=eq.${videoId}`,
        },
        (payload) => {
          const video = payload.new;
          setStatus(video.status);
          if (video.status === 'ready') {
            setPlaybackId(video.playback_id);
          }
        }
      )
      .subscribe();

    return () => {
      subscription.unsubscribe();
    };
  }, [videoId]);

  if (status === 'failed') {
    return (
      <View style={styles.container}>
        <Text style={styles.errorText}>
          Video generation failed. Please try again.
        </Text>
      </View>
    );
  }

  if (status === 'processing' || !playbackId) {
    return (
      <View style={styles.container}>
        <ActivityIndicator size="large" color="#007AFF" />
        <Text style={styles.text}>Generating your video...</Text>
      </View>
    );
  }

  return (
    <VideoView
      player={player}
      style={styles.video}
      nativeControls
    />
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  video: {
    width: '100%',
    aspectRatio: 16 / 9,
  },
  text: {
    marginTop: 10,
    fontSize: 16,
    color: '#666',
  },
  errorText: {
    fontSize: 16,
    color: '#ff0000',
    textAlign: 'center',
  },
});
```

### Complete AI video workflow example

Here's the full flow for an async video generation example app:

```tsx
import React, { useState } from 'react';
import {
  View,
  TextInput,
  TouchableOpacity,
  Text,
  ActivityIndicator,
  StyleSheet,
} from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';

export default function AIVideoGenerator() {
  const [prompt, setPrompt] = useState('');
  const [generating, setGenerating] = useState(false);
  const [videoId, setVideoId] = useState<string | null>(null);
  const [playbackId, setPlaybackId] = useState<string | null>(null);

  const player = useVideoPlayer(
    playbackId ? `https://stream.mux.com/${playbackId}.m3u8` : null,
    (player) => {
      player.loop = false;
    }
  );

  const generateVideo = async () => {
    if (!prompt.trim()) return;

    setGenerating(true);

    try {
      // Step 1: Submit prompt to your backend
      const response = await fetch('https://your-api.com/generate-video', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt }),
      });

      const { videoId } = await response.json();
      setVideoId(videoId);

      // Step 2: Your backend handles:
      // - Calling AI service (Fal.ai, Runway, etc.)
      // - Getting video URL from AI service
      // - Creating Mux asset from URL
      // - Waiting for Mux webhook

      // Step 3: Poll or subscribe for updates
      const checkStatus = async () => {
        const statusResponse = await fetch(
          `https://your-api.com/videos/${videoId}`
        );
        const video = await statusResponse.json();

        if (video.status === 'ready') {
          setPlaybackId(video.playbackId);
          setGenerating(false);
        } else if (video.status === 'failed') {
          setGenerating(false);
          alert('Video generation failed');
        } else {
          // Still processing, check again in 3 seconds
          setTimeout(checkStatus, 3000);
        }
      };

      checkStatus();
    } catch (error) {
      console.error('Generation failed:', error);
      setGenerating(false);
    }
  };

  if (playbackId) {
    return (
      <View style={styles.container}>
        <VideoView
          player={player}
          style={styles.video}
          nativeControls
        />
        <TouchableOpacity
          style={styles.button}
          onPress={() => {
            setPrompt('');
            setPlaybackId(null);
            setVideoId(null);
          }}
        >
          <Text style={styles.buttonText}>Generate Another</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.input}
        placeholder="Enter video prompt..."
        value={prompt}
        onChangeText={setPrompt}
        multiline
        editable={!generating}
      />

      {generating ? (
        <>
          <ActivityIndicator size="large" color="#007AFF" />
          <Text style={styles.statusText}>
            Generating your video...
          </Text>
          <Text style={styles.subText}>
            This usually takes 30-60 seconds
          </Text>
        </>
      ) : (
        <TouchableOpacity style={styles.button} onPress={generateVideo}>
          <Text style={styles.buttonText}>Generate Video</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    justifyContent: 'center',
  },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 15,
    fontSize: 16,
    marginBottom: 20,
    minHeight: 100,
  },
  button: {
    backgroundColor: '#007AFF',
    padding: 15,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
  video: {
    width: '100%',
    aspectRatio: 16 / 9,
    marginBottom: 20,
  },
  statusText: {
    fontSize: 16,
    marginTop: 10,
    textAlign: 'center',
  },
  subText: {
    fontSize: 14,
    color: '#666',
    marginTop: 5,
    textAlign: 'center',
  },
});
```

## Error handling

### Direct upload errors

Mobile networks are unreliable, so robust error handling is essential. Here are common upload errors and how to handle them:

**Common error scenarios:**

| Error | Cause | Solution |
|-------|-------|----------|
| Network timeout | Slow/unstable connection | Implement retry logic, allow resumable uploads |
| 403 Forbidden | Upload URL expired (valid for 48 hours) | Request a new upload URL from your backend |
| Connection lost | User switched from WiFi to cellular | Cancel upload, show option to retry |
| File too large | Video exceeds Mux limits | Validate file size before upload, compress if needed |
| Out of storage | Device storage full | Check available storage before upload |

**Enhanced error handling:**

```tsx
import { useState } from 'react';
import * as FileSystem from 'expo-file-system';
import * as Network from 'expo-network';

interface UploadError {
  message: string;
  canRetry: boolean;
  shouldRequestNewUrl: boolean;
}

export function useVideoUploadWithRetry() {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const [retryCount, setRetryCount] = useState(0);

  const MAX_RETRIES = 3;

  const handleUploadError = (error: any): UploadError => {
    const message = error.message?.toLowerCase() || '';

    // Network errors - can retry
    if (message.includes('network') || message.includes('connection')) {
      return {
        message: 'Network error. Check your connection and try again.',
        canRetry: true,
        shouldRequestNewUrl: false,
      };
    }

    // Timeout errors - can retry
    if (message.includes('timeout')) {
      return {
        message: 'Upload timed out. Try again or use a shorter video.',
        canRetry: true,
        shouldRequestNewUrl: false,
      };
    }

    // Upload URL expired - need new URL
    if (message.includes('403') || message.includes('forbidden')) {
      return {
        message: 'Upload URL expired. Requesting a new one...',
        canRetry: true,
        shouldRequestNewUrl: true,
      };
    }

    // Server errors - can retry
    if (message.includes('500') || message.includes('502') || message.includes('503')) {
      return {
        message: 'Server error. Retrying...',
        canRetry: true,
        shouldRequestNewUrl: false,
      };
    }

    // Client errors - cannot retry
    if (message.includes('400') || message.includes('413')) {
      return {
        message: 'Invalid video file or file too large.',
        canRetry: false,
        shouldRequestNewUrl: false,
      };
    }

    // Generic error
    return {
      message: 'Upload failed. Please try again.',
      canRetry: true,
      shouldRequestNewUrl: false,
    };
  };

  const uploadVideoWithRetry = async (
    videoUri: string,
    getUploadUrl: () => Promise<{ uploadUrl: string; uploadId: string }>
  ): Promise<{ uploadId: string } | null> => {
    setUploading(true);
    setUploadProgress(0);
    setError(null);
    setRetryCount(0);

    let uploadUrl: string;
    let uploadId: string;

    // Get initial upload URL
    try {
      const result = await getUploadUrl();
      uploadUrl = result.uploadUrl;
      uploadId = result.uploadId;
    } catch (err) {
      setError('Failed to get upload URL');
      setUploading(false);
      return null;
    }

    // Retry loop
    for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
      try {
        setRetryCount(attempt);

        // Check network connectivity before upload
        const networkState = await Network.getNetworkStateAsync();
        if (!networkState.isConnected) {
          throw new Error('No network connection');
        }

        // Create upload task
        const uploadTask = FileSystem.createUploadTask(
          uploadUrl,
          videoUri,
          {
            httpMethod: 'PUT',
            uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
          },
          (progress) => {
            const percentage =
              progress.totalBytesSent / progress.totalBytesExpectedToSend;
            setUploadProgress(Math.round(percentage * 100));
          }
        );

        const uploadResponse = await uploadTask.uploadAsync();

        if (!uploadResponse || uploadResponse.status !== 200) {
          throw new Error(`Upload failed with status ${uploadResponse?.status}`);
        }

        // Success!
        setUploading(false);
        return { uploadId };
      } catch (err: any) {
        console.error(`Upload attempt ${attempt + 1} failed:`, err);

        const errorInfo = handleUploadError(err);
        setError(errorInfo.message);

        // If we can't retry or we've exhausted retries, give up
        if (!errorInfo.canRetry || attempt === MAX_RETRIES - 1) {
          setUploading(false);
          setUploadProgress(0);
          return null;
        }

        // If URL expired, get a new one
        if (errorInfo.shouldRequestNewUrl) {
          try {
            const result = await getUploadUrl();
            uploadUrl = result.uploadUrl;
            uploadId = result.uploadId;
          } catch {
            setError('Failed to get new upload URL');
            setUploading(false);
            setUploadProgress(0);
            return null;
          }
        }

        // Wait before retrying (exponential backoff)
        const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }

    setUploading(false);
    setUploadProgress(0);
    return null;
  };

  return { uploadVideoWithRetry, uploading, uploadProgress, error, retryCount };
}
```

**Using the retry hook:**

```tsx
import * as ImagePicker from 'expo-image-picker';

function VideoUploader() {
  const { uploadVideoWithRetry, uploading, uploadProgress, error, retryCount } =
    useVideoUploadWithRetry();

  const selectAndUpload = async () => {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Videos,
    });

    if (result.canceled) return;

    const getUploadUrl = async () => {
      const response = await fetch('https://your-api.com/generate-upload-url', {
        method: 'POST',
      });
      return response.json();
    };

    await uploadVideoWithRetry(result.assets[0].uri, getUploadUrl);
  };

  return (
    <View>
      <TouchableOpacity onPress={selectAndUpload} disabled={uploading}>
        <Text>Select and Upload Video</Text>
      </TouchableOpacity>
      {uploading && (
        <View>
          <Text>Uploading: {uploadProgress}%</Text>
          {retryCount > 0 && <Text>Retry attempt {retryCount + 1}</Text>}
        </View>
      )}
      {error && <Text style={{ color: 'red' }}>{error}</Text>}
    </View>
  );
}
```

<Callout type="info">
  Install `expo-network` to check connectivity: `npx expo install expo-network`
</Callout>

### URL upload errors

Common issues when creating assets from URLs:

* **Invalid URL**: Ensure the URL is publicly accessible
* **Unsupported format**: Mux supports MP4, MOV, AVI, and more
* **File too large**: Check Mux's file size limits
* **URL expired**: Some AI services return temporary URLs

## Best practices

### For direct upload

1. **Show upload progress** - Use `FileSystem.createUploadTask` for progress updates
2. **Validate file size** - Check before uploading (e.g., max 5GB)
3. **Handle retries** - Network issues are common on mobile
4. **Compress videos** - Consider client-side compression for large files

### For URL upload

1. **Validate URLs** - Ensure they're publicly accessible before sending to Mux
2. **Handle temporary URLs** - Some AI services return URLs that expire quickly
3. **Store original URL** - Keep a reference in case you need to re-ingest
4. **Set timeouts** - AI video generation can take 30-120 seconds

### General

1. **Use webhooks** - More reliable than polling for asset status
2. **Store metadata** - Save prompt, user ID, timestamps in your database
3. **Handle failures gracefully** - Show clear error messages and retry options
4. **Monitor costs** - Track encoding and storage usage

<Callout type="info">
  Learn more about [Mux encoding tiers and pricing](https://www.mux.com/pricing) to optimize costs for your use case.
</Callout>
