::Users·React Native·AI

How to ship an AI mobile app fast with Expo

How CineMe shipped an AI selfie-to-video app to iOS and Android in just over a month, using Expo and EAS Build to skip platform divergence.

Kiril Kostev

Kiril Kostev

Guest Author

How to ship an AI app fast with Expo

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 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.

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, 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.

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:

This adds overhead but guarantees compatibility across devices.

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.

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.

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.

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.

  • 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

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

Or visit the website:

https://www.cineme.live/

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

Put yourself in a video!
expo-video
Expo Services

Share article