---
title: How to ship an AI mobile app fast with Expo
authors: Kiril Kostev
published: May 7, 2026
categories: Users, React Native, AI
tags: expo-video, Expo Services
---

_This is a guest post from Kiril Kostev - a London based software developer and curious and thoughtful creator, who likes learning about technology, business, and life._



[CineMe](https://www.cineme.live/) is a mobile app that turns a single selfie into a cinematic AI-generated video.

You upload a photo, choose a scene (action, anime, horror, etc.), and within seconds you get a short video of yourself transformed into that world - ready to post on TikTok or Reels.

![Cineme app](https://cdn.sanity.io/images/9r24npb8/production/d3e4f0a0a4164cbad0bf6ebba3824bc998411cd6-806x766.png)

The constraint was aggressive: I wanted to ship in under two weeks. That forced ruthless prioritization - no perfect UX, no over-engineering, just a tight loop from input → transformation → playback.

## Why Expo was the right choice for an AI video app

Expo was the obvious choice for this project. A single React Native codebase means no platform divergence. With [Expo’s Build service](https://docs.expo.dev/build/introduction/), I could go from local dev to TestFlight in one command. No wrestling with Xcode or Gradle. The ability to iterate quickly across iOS and Android removed entire categories of friction, which is exactly what you need when time is your main constraint.

## How the async AI video pipeline works

The app is built around a simple but effective async pipeline:

- **Expo (React Native app)**

Sends requests with a unique `X-Device-ID` header for lightweight user tracking.

- **Flask API**

Accepts a base64-encoded image and a scene prompt. This keeps the backend simple and avoids multipart upload complexity.

- **AI model (video generation)**

Handles the heavy lifting. The API returns immediately with a job ID and processes the video asynchronously.

- **Webhook callback → Flask**

Once generation completes, a webhook receives a notification. Flask then:

- Downloads the result
- Adds a watermark using `ffmpeg`
- Stores the processed video
- **Client polling (`/status`)**

The app polls every 5 seconds using the job ID until the video is ready.

- **Playback**

The final video is served back and played using [`expo-video`](https://docs.expo.dev/versions/latest/sdk/video/).

This architecture avoids blocking requests and keeps the app responsive, while still supporting long-running AI jobs.

## Four hard problems while building an AI video app on React Native

**1. File uploads (you can’t send `file://`)**

React Native file URIs are local to the device. Your server can’t access them. The solution:

- [Use `expo-file-system` to read the image](https://docs.expo.dev/versions/latest/sdk/filesystem/)
- Convert to base64 and send in the request body

This adds overhead but guarantees compatibility across devices.

```javascript
import { File } from 'expo-file-system';

async function imageUriToBase64(imageUri: string): Promise<string> {
  const file = new File(imageUri);
  const base64 = file.base64();
  return base64;
}

...
  const image_base64 = await imageUriToBase64(imageUri);
  const response = await fetch(`${BASE_URL}/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Device-ID': deviceId, 'AnyOtherHeaders': auth_header },
    body: JSON.stringify({
      image_base64: image_base64,
      scene_id: sceneId,
    }),
  });
```

**2. Async job pattern (submit → poll)**

AI generation isn’t instant. The correct pattern is:

- Submit request → receive `jobId`
- Store `jobId` in state
- Poll `/status` until complete
- Navigate to result screen

This decouples UI from processing and avoids timeouts.

```javascript
export async function pollJobStatus(jobId: string): Promise<JobStatus> {
  console.log('[CineMe API] Polling job status:', jobId);
  const deviceId = await getDeviceId();
  const response = await fetch(`${BASE_URL}/status/${jobId}`, {
    method:  'GET',
    headers: { 'Content-Type': 'application/json', 'X-Device-ID': deviceId, 'AnyOtherHeaders: auth_header },
  });

  if (!response.ok) {
    throw new ApiError(response.status, 'poll_failed', `HTTP ${response.status}`);
  }

  return response.json() as Promise<JobStatus>;
}
```



**3. Robust polling hook**

Polling sounds simple until networks fail. The production-ready version includes:

- Cleanup on unmount (avoid memory leaks)
- Timeout handling (fail after X seconds)
- Retry logic with `pollFailureCount >= 3`

This prevents infinite loops and improves resilience on flaky mobile networks.

```javascript
const startPolling = useCallback((jobId: string) => {
    pollFailureCount.current = 0;

    pollRef.current = setInterval(async () => {
      try {
        const status = await pollJobStatus(jobId);
        setProgress(status.progress);

        if (status.status === 'completed' && status.video_url) {
          cleanup();
          await refreshCredits();
          router.replace({
            pathname: '/result',
            params: {
              videoUrl:     status.video_url,
              sceneLabel:   paramsRef.current.sceneLabel ?? '',
              thumbnailUrl: status.thumbnail_url ?? '',
            },
          });
        } else if (status.status === 'failed') {
          cleanup();
          setFailedMessage({
            title:   'Generation failed',
            body:    status.error ?? "We couldn't generate your video. Please try again.",
            cta:     'Try Again',
            onPress: () => {
              jobIdRef.current = null;
              handleRetry();
            },
          });
          setFailed(true);
        }

      } catch (err: any) {
        pollFailureCount.current += 1;
        console.warn('[poll] error:', err, `(${pollFailureCount.current} failures)`);

        if (pollFailureCount.current >= 3) {
          cleanup();
          setFailedMessage({
            title:   'Connection lost',
            body:    'We lost connection to the server. Your video may still be generating. Tap to check again.',
            cta:     'Check Again',
            onPress: handleRetry,
          });
          setFailed(true);
        }
      }
    }, POLL_INTERVAL_MS);
  }, [cleanup, router]);
```

**4. Video playback constraints**

`useVideoPlayer` doesn’t reliably handle authenticated remote URLs. It expects a local `file://` URI.

Solution:

- Download the video locally after it's ready
- Cache it on device
- Pass the local path to the player

A helper like `useLocalVideo` wraps this logic (download → cache → return local URI). This dramatically improves playback reliability and startup time.

```javascript
useLocalVideo.tsx
import * as FileSystem from 'expo-file-system/legacy';

const [localUri, setLocalUri] = useState<string | null>(null);

...
        const cacheKey  = remoteUrl.split('/').pop();
        const localPath = `${FileSystem.cacheDirectory}${cacheKey}`;

        // Serve from cache if already downloaded
        const info = await FileSystem.getInfoAsync(localPath);
        if (info.exists) {
          if (!cancelled) {
            setLocalUri(localPath);
          }
          return;
        }

        const deviceId = await getDeviceId();
        if (!deviceId) throw new Error('No device ID');

        const download = await FileSystem.downloadAsync(
          remoteUrl,
          localPath,
          { headers: { 'X-Device-ID': deviceId, 'AnyOtherHeaders: auth_header } }
        );
        setLocalUri(download.uri);

result.tsx        
import { useVideoPlayer, VideoView } from 'expo-video';

const { localUri, loading, error } = useLocalVideo(videoUrl ?? null);

...        
  const player = useVideoPlayer(localUri ?? '', (p) => {
    p.loop = true;
    if (localUri) p.play();
  });
  
  {/* Video Player */}
  <View style={styles.videoWrap}>
    <VideoView
      player={player}
      style={styles.video}
      contentFit="contain"
    />
  </View>  
```

## Shipping to TestFlight and the app stores with Expo’s services

[Expo’s cloud services are the biggest force multiplier](https://expo.dev/services).

- `eas build --profile preview` lets you generate installable builds fast
- You can push to TestFlight the same day

The key insight: don’t wait for App Store approval to start distribution.

Instead:

- Upload to TestFlight immediately
- Start sharing with early users/influencers
- Gather feedback while Apple review is pending

This parallelizes growth and approval, saving days (or weeks).

## What I learned shipping CineMe with Expo

I missed my two week timeline. But in 36 days CineMe went from idea to a fully shipped product - live on both the App Store and Google Play. What started as a constraint-driven experiment is now a real product people can download, use, and share.

You can try it here:

iOS: [https://apps.apple.com/gb/app/cineme/id6760482145](https://apps.apple.com/gb/app/cineme/id6760482145)

Android: [https://play.google.com/store/apps/details?id=com.aiconversations.cineme&hl=en](https://play.google.com/store/apps/details?id=com.aiconversations.cineme&hl=en)

Or visit the website:

[https://www.cineme.live/](https://www.cineme.live/)

The biggest lesson: shipping fast beats planning forever. Once it’s live, real users - not assumptions - drive smarter iterations. 

[Video: Put yourself in a video!](https://youtube.com/shorts/dBnGtw9VKDM)

