「撮った動画をアップロードしているのに、エレベーターに入った瞬間に進捗バーが最初に戻る」— これを 2 回体験したユーザーは、もうあなたのアプリを開きません。
私は、過去に運営していた写真共有系アプリで同じ問題に直面しました。テストでは素直にアップロードできていた 200MB の動画が、本番では「iPhone を鞄に入れて電車に乗ったユーザー」の環境で 30〜40% の失敗率を叩き出していたのです。原因は単純で、fetch + FormData の素直な一括アップロードは、モバイル環境の前提と真正面からぶつかります。ここではその問題を根本から解決する実装パターンを、動くコード付きでお渡しします。
なぜ「ただの fetch + FormData」では本番で失敗するのか
モバイルアプリのファイルアップロードを「PC のブラウザと同じ感覚」で設計すると、ほぼ確実に以下の壁にぶつかります。
① ネットワークの瞬断が日常的に起こる。電車の駅間、地下街、建物の中、エレベーター、Wi‑Fi からモバイル回線への切り替え — これらの場面では TCP コネクションが切断されます。一括アップロードは切断のたびに最初からやり直しです。1GB のファイルなら、95% までアップロードしてから切れたら 950MB が無駄になります。
② iOS はバックグラウンド処理を容赦なく殺す。ユーザーがアプリをスワイプで閉じなくても、ホームに戻って 30 秒経つと、標準的な URLSession ではリクエストが打ち切られます。BGTaskScheduler や NSURLSession.background を使わない限り、「バックグラウンドでアップロード継続中」は幻想です。
③ メモリ制約が厳しい。1GB のファイルを readAsDataURL で読み込むとクラッシュします。React Native / Expo でメモリを測ると、フォアグラウンドで使えるのは機種にもよりますが 800MB〜1.5GB 程度。ファイル全体をメモリに載せる実装は即アウトです。
④ サーバー側にも制限がある。多くの PaaS(Vercel、Cloudflare Workers、Supabase Edge Functions)は、1 リクエストあたりのボディサイズやタイムアウトに上限があります。Cloudflare Workers は 100MB、Vercel Serverless は 4.5MB(標準)、API Gateway 経由の Lambda は 10MB。一括 POST では届きません。
この 4 つを同時に解決する設計が、レジュームアブル(再開可能)+ チャンク分割 + バックグラウンド対応 のアップロード基盤です。
3 つの選択肢を比較する — どれを採用するか
レジュームアブル・チャンク転送を実現する手段は主に 3 つあります。それぞれ得意領域が違うので、アプリの性質に合わせて選択します。
- TUS プロトコル(tus.io): HTTP ベースのオープン標準。クライアント実装が各言語で揃っており、サーバーも OSS(tusd)や Supabase Storage / Uppy Companion で動かせる。任意のチャンクサイズ、再開、並列は拡張仕様で。一般用途の万能解
- S3 / R2 Multipart Upload: AWS S3 の公式仕様。Cloudflare R2 や MinIO など S3 互換ストレージで動く。5MB〜5GB のパートを並列アップロード可能で、5TB まで扱える。クラウドストレージに直接入れる前提のアプリ向け
- Supabase Storage Resumable Upload: Supabase Storage は裏で TUS を採用しており、JS クライアントから数行で使える。Supabase バックエンドを使っているなら最短
私の採用基準はシンプルです。Cloudflare R2 をストレージに使っているなら Multipart Upload 一択、Supabase を使っているなら Supabase Storage Resumable、自前の API サーバーに入れる必要があれば TUS — 迷ったらこの順番で決まります。
Foundation: 進捗・キャンセル・再開を扱うアップロードコアの設計
どの選択肢を採るにしても、クライアント側の「アップロードコア」は共通化できます。以下の 3 つを担う薄い抽象層を先に作ると、後の実装がとても楽になります。
- UploadTask: ファイル 1 つ分のアップロードを表す状態機械(
idle / uploading / paused / failed / completed)
- ChunkScheduler: 大きいファイルをチャンクに分割し、並列度と再試行を管理する
- ProgressBus: 進捗・失敗・完了イベントを UI と永続層に流す
React Native + Expo 環境で Zustand を使った最小のコア実装を示します。これをベースに、後述の TUS / Multipart を差し替えて使います。
// store/uploadStore.ts
import { create } from 'zustand';
import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
type Status = 'idle' | 'uploading' | 'paused' | 'failed' | 'completed';
export interface UploadTask {
id: string;
fileUri: string;
fileName: string;
fileSize: number;
mimeType: string;
chunkSize: number; // バイト単位 (例: 5 * 1024 * 1024)
uploadedBytes: number; // 再開時のオフセット
status: Status;
serverUploadUrl?: string; // TUS / Multipart の Upload ID
errorMessage?: string;
retryCount: number;
}
interface UploadState {
tasks: Record<string, UploadTask>;
addTask: (task: UploadTask) => Promise<void>;
updateProgress: (id: string, uploadedBytes: number) => void;
setStatus: (id: string, status: Status, error?: string) => void;
hydrateFromStorage: () => Promise<void>;
}
const STORAGE_KEY = 'rork-upload-queue-v1';
export const useUploadStore = create<UploadState>((set, get) => ({
tasks: {},
addTask: async (task) => {
set((s) => ({ tasks: { ...s.tasks, [task.id]: task } }));
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(get().tasks));
},
updateProgress: (id, uploadedBytes) => {
const task = get().tasks[id];
if (!task) return;
// 1MB 進むたびに永続化(書き込み頻度を抑える)
const shouldPersist = uploadedBytes - task.uploadedBytes >= 1024 * 1024;
set((s) => ({
tasks: { ...s.tasks, [id]: { ...task, uploadedBytes } },
}));
if (shouldPersist) {
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(get().tasks));
}
},
setStatus: (id, status, error) => {
const task = get().tasks[id];
if (!task) return;
set((s) => ({
tasks: {
...s.tasks,
[id]: { ...task, status, errorMessage: error },
},
}));
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(get().tasks));
},
hydrateFromStorage: async () => {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const tasks = JSON.parse(raw) as Record<string, UploadTask>;
// 前回 uploading のまま落ちたタスクは paused として復帰
Object.values(tasks).forEach((t) => {
if (t.status === 'uploading') t.status = 'paused';
});
set({ tasks });
} catch (e) {
console.warn('upload-store hydrate failed', e);
}
},
}));
なぜ「1MB 進むたびに永続化」なのか — チャンク単位で毎回 AsyncStorage に書き込むと、書き込みが I/O ボトルネックになります。進捗の粒度と書き込み頻度のバランスを取るための選択で、経験的にはチャンクサイズの 20〜30% を目安にしています。
実装 1: TUS プロトコルでレジューム可能なアップロードを構築する
自前のサーバーや Supabase Storage で TUS を使う場合、React Native では tus-js-client がほぼそのまま動きます。以下は 1GB の動画を 5MB チャンクでアップロードする実装例です。
// lib/uploadTus.ts
import * as tus from 'tus-js-client';
import * as FileSystem from 'expo-file-system';
import { useUploadStore } from '../store/uploadStore';
// TUS サーバーへ Chunk 単位でアップロード
// 期待する動作: 5MB ごとにサーバーに POST/PATCH → 途中切断でも再開可能
export async function uploadWithTus(
taskId: string,
endpoint: string,
fileUri: string,
fileName: string,
mimeType: string,
): Promise<void> {
const store = useUploadStore.getState();
const task = store.tasks[taskId];
if (!task) throw new Error(`task not found: ${taskId}`);
// Expo 上では Blob を直接扱えないため、ファイルをストリームで読み込む
const fileInfo = await FileSystem.getInfoAsync(fileUri);
if (!fileInfo.exists) throw new Error('file missing');
return new Promise((resolve, reject) => {
const upload = new tus.Upload(
// React Native 用の軽量ラッパー
{
uri: fileUri,
name: fileName,
type: mimeType,
size: fileInfo.size,
} as any,
{
endpoint,
chunkSize: 5 * 1024 * 1024, // 5MB
retryDelays: [0, 1000, 3000, 5000, 10000], // 指数バックオフ
metadata: { filename: fileName, filetype: mimeType },
// サーバー側が応答した Upload-Offset を受け取って進捗計算
onProgress: (bytesUploaded) => {
store.updateProgress(taskId, bytesUploaded);
},
onSuccess: () => {
store.setStatus(taskId, 'completed');
resolve();
},
onError: (err) => {
store.setStatus(taskId, 'failed', err.message);
reject(err);
},
// 途中再開用: 前回の serverUploadUrl があればそこから復帰
uploadUrl: task.serverUploadUrl,
},
);
// 初回アップロード時に URL を発行 → 永続化
upload.findPreviousUploads().then((previousUploads) => {
if (previousUploads.length > 0) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
store.setStatus(taskId, 'uploading');
upload.start();
});
});
}
期待する動作: アプリがバックグラウンドから復帰したとき、tus-js-client はサーバーに HEAD リクエストを投げて「今どこまで受け取ったか」を問い合わせ、そのオフセットから続きを送ります。仮に 700MB 送った時点で切断しても、再開時には 701MB 目から送信されます。
Expo での注意点: 純粋な tus-js-client は File / Blob 前提なので、React Native 環境では内部的に XMLHttpRequest + FormData で送る必要があります。Expo SDK 52 以降は expo-file-system の createUploadTask が部分的に TUS 互換のため、自前実装なら後述のアプローチも検討してください。
実装 2: S3 / R2 の Multipart Upload で 5GB ファイルを並列転送する
Cloudflare R2 や AWS S3 に直接アップロードする場合、クライアントがサーバーを経由せずに直接ストレージに書き込める のが大きなメリットです。サーバーは事前署名 URL を発行するだけで済み、帯域課金も抑えられます。
実装の流れは以下の 3 ステップです。
- ① Initiate Multipart: サーバーで
CreateMultipartUpload を呼び、uploadId を取得
- ② Upload Parts: クライアントが各パート用の事前署名 URL に並列 PUT
- ③ Complete Multipart: 全パート完了後、
CompleteMultipartUpload で結合
以下がクライアント側のコア実装です。
// lib/uploadMultipart.ts
import * as FileSystem from 'expo-file-system';
import { useUploadStore } from '../store/uploadStore';
interface InitiateResponse {
uploadId: string;
key: string;
}
interface PartUrl {
partNumber: number;
url: string;
}
const PART_SIZE = 5 * 1024 * 1024; // R2/S3 最小は 5MB
const PARALLELISM = 3; // モバイル回線では 3 並列が上限
// サーバー API と協調して Multipart Upload を実行
// 期待する動作: 1GB のファイルが 200 パートに分割され、3 並列で順次アップロードされる
export async function uploadWithMultipart(
taskId: string,
fileUri: string,
fileName: string,
apiBaseUrl: string,
authToken: string,
): Promise<void> {
const store = useUploadStore.getState();
const task = store.tasks[taskId];
if (!task) throw new Error('task not found');
const fileInfo = await FileSystem.getInfoAsync(fileUri);
if (!fileInfo.exists) throw new Error('file missing');
const totalSize = fileInfo.size;
const totalParts = Math.ceil(totalSize / PART_SIZE);
try {
store.setStatus(taskId, 'uploading');
// Step 1: Multipart 開始
const initRes = await fetch(`${apiBaseUrl}/uploads/initiate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ fileName, size: totalSize, totalParts }),
});
if (!initRes.ok) throw new Error(`initiate failed: ${initRes.status}`);
const init = (await initRes.json()) as InitiateResponse;
// 再開用: uploadId を永続化
store.setStatus(taskId, 'uploading');
// Step 2: 全パート用の事前署名 URL を取得
const partUrlsRes = await fetch(`${apiBaseUrl}/uploads/part-urls`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
uploadId: init.uploadId,
key: init.key,
totalParts,
}),
});
const { parts } = (await partUrlsRes.json()) as { parts: PartUrl[] };
// Step 3: 並列アップロード
const completedParts: { PartNumber: number; ETag: string }[] = [];
let uploadedBytes = task.uploadedBytes || 0;
async function uploadPart(part: PartUrl): Promise<void> {
const start = (part.partNumber - 1) * PART_SIZE;
const end = Math.min(start + PART_SIZE, totalSize);
const partSize = end - start;
// expo-file-system で指定範囲をチャンク読み込み
const base64 = await FileSystem.readAsStringAsync(fileUri, {
encoding: FileSystem.EncodingType.Base64,
position: start,
length: partSize,
});
const binary = decodeBase64ToUint8Array(base64);
const res = await fetch(part.url, {
method: 'PUT',
body: binary as any,
});
if (!res.ok) throw new Error(`part ${part.partNumber} failed`);
const etag = res.headers.get('ETag')?.replace(/"/g, '') || '';
completedParts.push({ PartNumber: part.partNumber, ETag: etag });
uploadedBytes += partSize;
store.updateProgress(taskId, uploadedBytes);
}
// 同時実行数を制限しながら全パートを流す
const queue = [...parts];
const workers = Array.from({ length: PARALLELISM }, async () => {
while (queue.length > 0) {
const next = queue.shift();
if (!next) return;
await retryWithBackoff(() => uploadPart(next), 3);
}
});
await Promise.all(workers);
// Step 4: Complete
completedParts.sort((a, b) => a.PartNumber - b.PartNumber);
const completeRes = await fetch(`${apiBaseUrl}/uploads/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
uploadId: init.uploadId,
key: init.key,
parts: completedParts,
}),
});
if (!completeRes.ok) throw new Error('complete failed');
store.setStatus(taskId, 'completed');
} catch (e: any) {
store.setStatus(taskId, 'failed', e.message);
throw e;
}
}
function decodeBase64ToUint8Array(base64: string): Uint8Array {
const binString = atob(base64);
const bytes = new Uint8Array(binString.length);
for (let i = 0; i < binString.length; i++) bytes[i] = binString.charCodeAt(i);
return bytes;
}
async function retryWithBackoff<T>(fn: () => Promise<T>, max: number): Promise<T> {
let lastErr: any;
for (let i = 0; i < max; i++) {
try {
return await fn();
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 500 * Math.pow(2, i)));
}
}
throw lastErr;
}
期待する動作: 1GB のファイルが 200 個の 5MB パートに分割され、3 並列で順次アップロードされます。どのパートが失敗しても、そのパートだけ再試行すれば済みます。すべてのパート完了後、サーバーで CompleteMultipartUpload を呼ぶと R2 側で 1 つのオブジェクトに結合されます。
なぜ並列度 3 か — 私の実測では、モバイル回線(4G / 5G)での並列度は 3 がスイートスポットです。5 以上にすると、個々の接続のスループットが下がって全体時間がむしろ悪化します。Wi‑Fi 環境なら 5〜6 まで上げて良いので、NetInfo で接続種別を見て動的に決めるのも有効です。
iOS バックグラウンドアップロードの実装 — BGTaskScheduler との連携
フォアグラウンドで始めたアップロードを、ユーザーがホームに戻った後も継続させたい — これを実現するのが BGTaskScheduler と URLSessionConfiguration.background です。Expo 環境では expo-background-fetch + expo-task-manager が使えます。
// lib/backgroundUpload.ts
import * as BackgroundFetch from 'expo-background-fetch';
import * as TaskManager from 'expo-task-manager';
import { useUploadStore } from '../store/uploadStore';
import { uploadWithMultipart } from './uploadMultipart';
const TASK_NAME = 'rork-resumable-upload';
// バックグラウンドで未完了タスクを拾って続きから送信する
// 期待する動作: アプリが閉じた後も iOS が適切なタイミングで TaskManager.defineTask を起動する
TaskManager.defineTask(TASK_NAME, async () => {
try {
await useUploadStore.getState().hydrateFromStorage();
const tasks = useUploadStore.getState().tasks;
const pending = Object.values(tasks).filter(
(t) => t.status === 'paused' || t.status === 'failed',
);
for (const task of pending) {
try {
await uploadWithMultipart(
task.id,
task.fileUri,
task.fileName,
process.env.EXPO_PUBLIC_API_URL!,
await getAuthToken(), // KeyChain / SecureStore から取得
);
} catch (e) {
console.warn('bg upload failed', task.id, e);
}
}
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (e) {
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
export async function registerBackgroundUpload(): Promise<void> {
await BackgroundFetch.registerTaskAsync(TASK_NAME, {
minimumInterval: 60 * 15, // 15 分 (iOS は 15 分未満は無視)
stopOnTerminate: false,
startOnBoot: true,
});
}
async function getAuthToken(): Promise<string> {
// 省略: expo-secure-store から JWT を取り出す実装
return '';
}
iOS の制約を理解しておく — BackgroundFetch で登録したタスクは、iOS が「この時間帯なら実行してよい」と判断したときに呼ばれます。ユーザーの使用パターン、電池残量、回線状態で左右されるため、「今すぐバックグラウンドで続けろ」という命令はできません。minimumInterval は目安であり、実際の呼び出し間隔は数時間空くこともあります。
本当にシビアなリアルタイム性が必要なら URLSessionConfiguration.background をネイティブモジュールとして露出させるしかありません。Expo の公式モジュールでは 2026 年 4 月時点で完全なラッパーが提供されていないため、必要ならネイティブ実装を書くか、react-native-background-upload のような既存モジュールを検討します。
ユーザー体験: 進捗表示・キャンセル・エラー復旧の UI 設計
転送の技術的な仕組みが動いても、ユーザーには「今何が起きているか」が伝わらないと離脱されます。以下は Zustand ストアと繋いだ進捗 UI の実装例です。
// components/UploadProgressList.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useUploadStore, UploadTask } from '../store/uploadStore';
export function UploadProgressList() {
const tasks = useUploadStore((s) => Object.values(s.tasks));
if (tasks.length === 0) return null;
return (
<View style={styles.container}>
{tasks.map((task) => (
<UploadRow key={task.id} task={task} />
))}
</View>
);
}
function UploadRow({ task }: { task: UploadTask }) {
const percent = Math.min(100, (task.uploadedBytes / task.fileSize) * 100);
const setStatus = useUploadStore((s) => s.setStatus);
const statusLabel = {
idle: '待機中',
uploading: `アップロード中 ${percent.toFixed(0)}%`,
paused: '一時停止 — タップで再開',
failed: `失敗 — ${task.errorMessage ?? ''}`,
completed: '完了',
}[task.status];
return (
<Pressable
onPress={() => {
if (task.status === 'paused' || task.status === 'failed') {
// 再開トリガー
setStatus(task.id, 'uploading');
}
}}
style={styles.row}
>
<Text style={styles.fileName} numberOfLines={1}>
{task.fileName}
</Text>
<View style={styles.barBg}>
<View style={[styles.barFill, { width: `${percent}%` }]} />
</View>
<Text style={styles.status}>{statusLabel}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 12 },
row: { gap: 6 },
fileName: { fontSize: 14, fontWeight: '600' },
barBg: { height: 6, backgroundColor: '#eee', borderRadius: 3 },
barFill: { height: 6, backgroundColor: '#4F7BFF', borderRadius: 3 },
status: { fontSize: 12, color: '#666' },
});
見落とされがちだが大事な配慮:
- アプリ起動時のレジューム確認: 前回
uploading 状態で落ちたタスクは、起動時に paused に落とした上で「再開しますか?」と提示します。自動再開してしまうと、モバイル回線下の大量転送で課金事故を起こす可能性があります
- キャンセル時のクリーンアップ: S3 Multipart の場合、
AbortMultipartUpload を呼ばないと未完了パートが 7 日間保持されて課金対象になります。キャンセル時は必ずサーバー経由で中止 API を叩きます
- Wi‑Fi 限定モード: 設定で「Wi‑Fi 時のみアップロード」オプションを用意します。
NetInfo.fetch() でタイプを確認し、モバイル回線時は paused に留める
関連記事として、Rork アプリで画像とメディアのネットワーク通信をデバッグする手順 も合わせて読むと、「アップロードが遅い原因がコードではなくサーバー側にある」ケースの切り分けに役立ちます。
よくある落とし穴 — 3 つの本番障害パターン
本番で実際に起きた失敗例と、その対処法を具体的に挙げておきます。
① メモリリーク: チャンク読み込みの Base64 が GC されない
FileSystem.readAsStringAsync で Base64 を受け取ると、大きな文字列が JS ヒープに残りがちです。1GB のファイルを 200 チャンクで読むと、途中で OOM します。対処は、チャンクごとにローカル変数を明示的に null に落とし、Promise.all の batch 数を小さく保つこと。私の実装では 並列度 3 × チャンク 5MB = 常時メモリ 15MB に抑えています。
② 並列度の罠: 多ければ速いわけではない
前述の通り、モバイル回線で並列度を上げすぎると遅くなります。特に 3G/4G の弱電波下では、並列 5 より並列 2 のほうが速い場面がありました。NetInfo の cellularGeneration を見て動的に調整するのが本番の最適解です。
③ Charge Interruption: iOS の低電力モードで打ち切られる
iOS は電池残量 20% 以下で低電力モードに入ると、バックグラウンドタスクを強制終了します。ExpoBattery.getPowerStateAsync で lowPowerMode を検知したら、ユーザーに「アップロードを続けるには電源接続をお勧めします」とアラートを出すと離脱を減らせます。
④ Android のバックグラウンド制限
Android 12 以降は、フォアグラウンドサービスの種別を明示的に宣言しないと、アプリが閉じられた時点でアップロードが打ち切られます。react-native-background-actions の dataSync タイプを使うか、WorkManager をネイティブ実装で呼ぶ必要があります。Expo EAS では app.json の Android permissions に FOREGROUND_SERVICE_DATA_SYNC を追加してください。
ここで挙げた落とし穴は、すべて本番運用で「数万件のアップロードを処理した後に初めて気づくタイプ」のものです。初回実装でここまで詰めておかないと、後から治すのが非常に高コストになります。
サーバー側の実装要点 — 4 つのエンドポイントを最小構成で
これまでの実装はクライアントが 4 つの API を呼ぶ前提で書いてきました。記事を完結させるために、Cloudflare Workers + Hono + R2 バインディングで書いた最小実装を示します。既存の API に追加して、wrangler.toml で R2_BUCKET バインディングを繋げば動きます。
// api/uploads.ts (Cloudflare Workers + Hono)
import { Hono } from 'hono';
import { AwsClient } from 'aws4fetch';
type Env = {
R2_BUCKET: R2Bucket;
R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
R2_BUCKET_NAME: string;
};
const app = new Hono<{ Bindings: Env }>();
// 1. Multipart 開始: uploadId とオブジェクトキーを返す
app.post('/uploads/initiate', async (c) => {
const { fileName, size, totalParts } = await c.req.json<{
fileName: string;
size: number;
totalParts: number;
}>();
if (size > 5 * 1024 * 1024 * 1024) return c.json({ error: 'too large' }, 400);
const key = `u/${crypto.randomUUID()}/${fileName}`;
const multipart = await c.env.R2_BUCKET.createMultipartUpload(key);
return c.json({ uploadId: multipart.uploadId, key });
});
// 2. 各パート用の事前署名 URL を発行(有効期限 1 時間)
app.post('/uploads/part-urls', async (c) => {
const { uploadId, key, totalParts } = await c.req.json<{
uploadId: string;
key: string;
totalParts: number;
}>();
const aws = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const endpoint = `https://${c.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
const parts: { partNumber: number; url: string }[] = [];
for (let i = 1; i <= totalParts; i++) {
const url = `${endpoint}/${c.env.R2_BUCKET_NAME}/${encodeURIComponent(key)}?partNumber=${i}&uploadId=${uploadId}`;
const signed = await aws.sign(url, {
method: 'PUT',
aws: { signQuery: true, expiresIn: 60 * 60 },
});
parts.push({ partNumber: i, url: signed.url });
}
return c.json({ parts });
});
// 3. 全パート完了後に Multipart を結合する
app.post('/uploads/complete', async (c) => {
const { uploadId, key, parts } = await c.req.json<{
uploadId: string;
key: string;
parts: { PartNumber: number; ETag: string }[];
}>();
const multipart = c.env.R2_BUCKET.resumeMultipartUpload(key, uploadId);
const object = await multipart.complete(
parts.map((p) => ({ partNumber: p.PartNumber, etag: p.ETag })),
);
return c.json({ ok: true, key: object.key, size: object.size });
});
// 4. クライアントのキャンセル経路: 未完了 Multipart を中止する
app.post('/uploads/abort', async (c) => {
const { uploadId, key } = await c.req.json<{ uploadId: string; key: string }>();
const multipart = c.env.R2_BUCKET.resumeMultipartUpload(key, uploadId);
await multipart.abort();
return c.json({ ok: true });
});
export default app;
なぜ R2 のネイティブバインディングと aws4fetch を併用するのか。R2 の Workers バインディングは createMultipartUpload / resumeMultipartUpload を直接提供してくれますが、事前署名 URL の発行機能がありません。クライアントが Worker を経由せずにパートを PUT するためには事前署名 URL が必要で、そこだけ aws4fetch で S3 互換 REST に対して SigV4 署名をかけます。開始・完了・中止はバインディング側を使ったほうが速くてシンプルです。
認証を必ず追加する。これらのルートは Bearer トークン(JWT、Supabase セッション、使っているものであれば何でも)を検証するミドルウェアで保護します。未認証で公開すると、誰でも Multipart Upload を開始できてストレージ予算を焼かれます。
リリース前のテストシナリオ
アップロード機能は「会社の Wi‑Fi で動いたからリリース」が通用しない分野です。私がリリース前に必ず回しているテスト一覧を共有します。
① Network Link Conditioner (iOS) / Chrome DevTools スロットル: 「Very Bad Network」(1% ロス、500ms RTT)で、どのファイルサイズでも最終的に完了すること。時間はかかって当然ですが、失敗して終わるのは NG。
② 機内モードフリップテスト: 200MB のアップロードを開始し、進捗 30% の時点で機内モードを 10 秒オン → オフ。自動で 15 秒以内に再開し、ゼロではなく永続化済みのオフセットから進むこと。
③ 強制終了からの復帰テスト: 500MB のアップロードを 50% まで進め、アプリスイッチャーから強制終了。30 秒後に再起動すると、タスクは paused 状態で表示されているはず。タップすれば中断地点の近く(数秒の再検証は許容範囲)から再開します。
④ バックグラウンド連続テスト: アップロード開始直後にホームへ。20 分後に復帰すると、iOS のスケジューリング制約内で進捗が進んでいること。フォアグラウンド復帰時に UploadProgressList がバックグラウンドからの進捗更新を反映することを確認します。
⑤ キャンセルリークテスト: 10 ファイルをランダムな進捗でキャンセル。ストレージのダッシュボードを確認し、未完了の Multipart が 0 件であること。残っていれば abort API が正しく叩かれていません。
⑥ 大容量ストレステスト: アプリで扱い得る最大サイズ(例えば 2GB の 4K 動画)を、iOS と Android、Wi‑Fi と 4G/5G の 4 条件で試す。完了時間・失敗回数・ピークメモリを記録。これが本番でモニタリングする KPI の初期値になります。
①〜⑤ は Maestro や Detox で CI に載せられると理想的ですが、経験的にはリリースサイクルごとに手動 QA で回すだけでも、退行の 90% を低コストで捕捉できます。
本番運用チェックリスト
最後に、レジューム対応アップロードを本番に出す前に確認すべき項目をまとめます。内容が多いので、リリース前のスプリントで 1 項目ずつ潰していくのをおすすめします。
- サーバー側:
uploadId の有効期限を設定しているか(S3/R2 のデフォルトは無期限 — 24 時間で AbortMultipartUpload を呼ぶ Cron を用意)
- サーバー側: パート番号の範囲バリデーション(1〜10,000 の外は S3 が拒否)を入れているか
- サーバー側: 完了時のファイルサイズがクライアント申告と一致するか検証しているか(改ざん防止)
- クライアント: 事前署名 URL の有効期限(推奨 1 時間)を過ぎたら再発行するロジックがあるか
- クライアント: アプリ再起動時に
hydrateFromStorage を呼んで未完了タスクを復元しているか
- クライアント: キャンセル時に
AbortMultipartUpload を呼んでいるか(課金事故防止)
- UX: 「アップロード中に閉じないでください」と出さず、「閉じても大丈夫です」と明示しているか(バックグラウンド対応の信頼性を伝える)
- UX: 失敗時のメッセージが「再試行してください」で終わっていないか — 「Wi‑Fi に接続してから再開します」など具体的な行動を示す
- 監視: Sentry / Firebase Crashlytics に「完了率」「平均所要時間」「失敗理由別の分布」をダッシュボード化しているか
- 監視: サーバー側で
uploadId ごとの進捗を追跡し、1 時間以上動きがないものを自動中止する
関連して、アプリ本体を軽量化する観点からは Rork アプリのバイナリサイズを切り分ける手順 もおすすめです。アップロード機能を足すと依存パッケージが増えがちで、バイナリサイズの管理と並行して見ていく必要があります。Cloudflare R2 を使う場合は Rork × Cloudflare R2 のファイルストレージ実装ガイド に、事前署名 URL を発行するサーバー側の最小実装が載っています。
アップロード基盤は、一度作ってしまえば他の機能の土台として何度も使い回せる資産になります。「動画が途中で切れる」という数行の CS 報告の裏には、ユーザーの信頼を失っている時間が積み重なっています。この記事の実装を、ぜひあなたのアプリの核として育ててください。
書籍で体系的に
今日できる最初の一歩は、手元のアプリで 500MB の動画を電車内でアップロードしてみること。失敗率を記録しておけば、この記事の実装を導入した後の改善幅が数字で見えるようになります。