A resilient, persistent file-upload queue for React Native.
Mobile uploads fail for reasons that have nothing to do with your API contract: the process dies, the radio drops, Wi-Fi hands off to cellular, the OS suspends the app, the token expires mid-request, or the server answers 429 / 500 after two minutes of transfer. A single fetch() cannot represent that lifecycle.
This library persists each upload locally, processes the queue with bounded concurrency, retries only recoverable failures, and recovers abandoned work after a restart.
0.1.0 promise: persist file uploads on device and process them reliably with retries, progress tracking, concurrency control, and automatic recovery after application restarts.
| Package | @erickmorales91/react-native-upload-queue |
| Runtime | React Native / TypeScript (Hermes-safe core) |
| Persistence | SQLite via a pluggable driver, plus an in-memory adapter for tests |
| Transport | HTTP multipart, one attempt per call |
| Connectivity | NetInfo adapter or a manual provider |
| License | MIT |
Also available in Spanish.
This package does not depend on react-native-resilient-sync. They belong to the same resilience family and can run side by side. Sync is for JSON domain operations. This library is for files.
- When to use it
- The problem
- What this library guarantees
- What this is not
- Installation
- Quick start
- A complete example
- Creating the queue
- Enqueueing uploads
- Processing the queue
- Pause, resume, cancel, retry
- Events and UI progress
- Storage adapters
- HTTP transport
- Connectivity
- Retries and error classification
- Authentication
- Recovery after restart
- Idempotency
- Progress semantics
- Relationship with react-native-resilient-sync
- Security
- Testing
- API reference
- Limitations and roadmap
- License
Use this library when the product cannot afford to lose a file just because the device left coverage.
Typical workloads:
- field inspections: photos and evidence captured offline
- invoicing: PDFs attached in a warehouse or on the road
- healthcare / operations: documents and audio notes
- chat or ticketing: user-selected images and videos
- any flow where “the user already tapped send” must eventually reach the server
If the file is tiny, the network is guaranteed, and losing the request is acceptable, a direct fetch is enough. If the user may background the app, lose signal, or retry the same document, you need a queue.
The naive implementation looks correct:
await fetch('/uploads', {
method: 'POST',
body: file,
});It works while all of the following are true:
- the radio is stable
- the app stays in the foreground
- the request is short
- the file is small
- the server answers
2xxon the first try
On a real device any of these can happen instead:
| Failure | What a raw fetch does |
What the user experiences |
|---|---|---|
| App killed mid-upload | The request vanishes | The file is gone from the UI; they upload again |
| Tunnel / elevator / rural coverage | Throws a network error | They hammer retry and create duplicates |
| Wi-Fi → 4G handoff | The socket dies | Progress resets with no record of the attempt |
500 / 502 / 503 |
The caller must invent retry logic | Inconsistent UX per screen |
429 + Retry-After |
Usually ignored | The client DDoSes itself |
401 with a stale token |
Five retries with the same header | Account lockouts, wasted battery |
Temporary file:// URI expires |
Five retries of a missing file | Noise in logs, no useful diagnosis |
The library turns that into a durable state machine:
Select file
↓
Persist the upload (SQLite)
↓
Enter the queue as pending
↓
Claim + attempt HTTP once
↓
Recoverable failure? persist + wait + retry
↓
Server confirms
↓
completed
-
Persist before transmit. The record exists locally before the first byte is sent. Killing the app does not drop the intent to upload.
-
One HTTP attempt per transport call.
HttpUploadTransportdoes not retry. The processor owns backoff,Retry-After, max attempts, andblocked. Those concerns stay separated on purpose. -
Claiming, not “select pending and hope”. A row moves
pending → uploadingwith aprocessingToken. Twoprocess()calls on the same instance cannot double-send the same file. -
Concurrency is a hard cap.
concurrency: 2means at most two in-flight uploads for that queue instance, even if the UI firesprocess()three times. -
Offline does not burn retries. Five minutes without signal does not consume five attempts.
attemptsincreases only after a real upload try. -
Stable idempotency keys. Attempt 1, 2 and 3 send the same
Idempotency-Key. The server still has to implement the semantics; the client will not rotate the key. -
Structured errors. Failures are
network | authentication | authorization | rate-limit | validation | server | file-not-found | cancelled | unknown, withretryableand optionalstatusCode/retryAfterMs. -
Abandoned
uploadingrows are recovered. After a crash, rows older thanprocessingTimeoutMsreturn topendingand are attempted again. -
Tokens never touch SQLite.
getAccessTokenruns on every attempt. The queue stores URI, destination, metadata, and status — notAuthorization.
0.1.0 is a resilient upload queue, not a resumable upload protocol.
Persisting progress: 0.53 does not mean the next attempt continues at byte 53%. Unless the server speaks TUS, S3 multipart, or another chunk protocol, a retry starts from byte 0. Progress is for the UI.
Out of scope in this version:
- TUS, S3 multipart, chunked resume
- Firebase Storage, Supabase Storage, Cloudinary, AWS / Azure / GCS SDKs
- GraphQL or WebSockets
- native background-upload services
- encryption, image compression, video transcoding, thumbnails
- bidirectional sync
- a bundled UI or a required Redux / Zustand store
Those belong in later versions or in the application layer.
npm install @erickmorales91/react-native-upload-queueThe core has no runtime dependency on react-native, NetInfo, or a specific SQLite engine. That is why the same code runs in Node tests.
For production you supply:
- a native SQLite module (
react-native-quick-sqlite,op-sqlite,expo-sqlite, …) behindSQLiteDriver - optionally
@react-native-community/netinfo
Use the exported createId() when you need a UUID. crypto.randomUUID() is missing on Hermes.
import {
createUploadQueue,
createSQLiteUploadStorage,
createHttpUploadTransport,
} from '@erickmorales91/react-native-upload-queue';
const queue = createUploadQueue({
storage: createSQLiteUploadStorage({
databaseName: 'uploads.db',
openDriver: (databaseName) => openYourSqliteDriver(databaseName),
}),
transport: createHttpUploadTransport({
baseUrl: 'https://api.example.com',
getAccessToken: async () => auth.getAccessToken(),
}),
concurrency: 2,
retry: {
maxAttempts: 5,
strategy: 'exponential',
},
});
const upload = await queue.enqueue({
fileUri: file.uri,
fileName: file.name,
mimeType: file.type,
destination: '/uploads',
});
await queue.start();enqueue returns immediately with an id. start() recovers abandoned rows, processes due work, and keeps listening for reconnects.
A field app that attaches an invoice photo and a PDF, shows progress, and survives going underground:
import NetInfo from '@react-native-community/netinfo';
import {
createHttpUploadTransport,
createNetInfoConnectivityProvider,
createSQLiteUploadStorage,
createUploadQueue,
type UploadQueueEvent,
type UploadTask,
} from '@erickmorales91/react-native-upload-queue';
const queue = createUploadQueue({
storage: createSQLiteUploadStorage({
databaseName: 'uploads.db',
openDriver: openYourSqliteDriver,
}),
transport: createHttpUploadTransport({
baseUrl: 'https://api.example.com',
fieldName: 'file',
idempotencyHeader: 'Idempotency-Key',
getAccessToken: () => auth.getAccessToken(),
timeoutMs: 60_000,
}),
connectivity: createNetInfoConnectivityProvider({ netInfo: NetInfo }),
concurrency: 2,
retry: {
maxAttempts: 5,
initialDelayMs: 2_000,
maxDelayMs: 32_000,
jitter: true,
},
recovery: {
processingTimeoutMs: 5 * 60_000,
},
});
export async function bootstrapUploads(): Promise<void> {
queue.subscribe(onUploadEvent);
await queue.start();
}
export async function submitEvidence(file: {
uri: string;
name: string;
type: string;
size?: number;
}): Promise<UploadTask> {
return queue.enqueue({
fileUri: file.uri,
fileName: file.name,
mimeType: file.type,
...(file.size !== undefined ? { size: file.size } : {}),
destination: '/documents',
metadata: {
documentType: 'invoice',
inspectionId: currentInspectionId,
},
});
}
function onUploadEvent(event: UploadQueueEvent): void {
switch (event.type) {
case 'upload.progress':
updateRow(event.uploadId, {
progress: event.progress,
bytesUploaded: event.bytesUploaded,
});
break;
case 'upload.completed':
markDone(event.uploadId, event.remoteId);
break;
case 'upload.blocked':
askUserToSignInAgain();
break;
case 'upload.failed':
showFailure(event.uploadId, event.error.message);
break;
default:
break;
}
}The screen only renders queue state. It does not own retry timers, NetInfo subscriptions, or SQLite writes.
Uploads
photo.jpg ████████░░ 80% uploading
invoice.pdf ░░░░░░░░░░ 0% pending
video.mp4 ██████████ 100% completed
const queue = createUploadQueue({
storage,
transport,
connectivity, // optional
fileProvider, // optional; default assumes the URI still exists
retry: {
maxAttempts: 5,
strategy: 'exponential', // or 'fixed', or a custom RetryStrategy
initialDelayMs: 2_000,
maxDelayMs: 60_000,
jitter: true,
},
concurrency: 2,
recovery: { processingTimeoutMs: 5 * 60_000 },
progress: {
eventThrottleMs: 200,
persistEveryPercent: 0.1,
persistEveryMs: 500,
},
autoProcessOnReconnect: true,
});| Option | Default | Role |
|---|---|---|
concurrency |
2 |
Max simultaneous uploads in this instance |
retry.maxAttempts |
5 |
Attempts that actually hit the network |
recovery.processingTimeoutMs |
5 min |
When an uploading row is considered abandoned |
progress.eventThrottleMs |
200 |
UI events, not SQLite writes |
autoProcessOnReconnect |
true |
Only while start() has been called |
const upload = await queue.enqueue({
fileUri: file.uri,
fileName: file.name,
mimeType: file.type,
size: file.size,
destination: '/documents',
method: 'POST', // or 'PUT'
metadata: { documentType: 'invoice' },
});
upload.id;
upload.status; // 'pending'
upload.idempotencyKey; // stable for every later attemptThe original file is not copied into SQLite. The queue stores the URI and small metadata (8 KiB cap). If the OS recycles a temporary URI, the next attempt fails as file-not-found instead of retrying a ghost path five times.
await queue.start(); // recover + process + listen for reconnect + schedule backoff
await queue.process(); // one drain of currently due work (ideal for tests)
await queue.stop(); // stop automatic scheduling; in-flight attempts finish
await queue.destroy(); // stop, unsubscribe, close storageprocess() is single-flight. A second overlapping call returns { skipped: true, reason: 'busy' } instead of launching a parallel worker pool.
If connectivity reports offline, process() returns { skipped: true, reason: 'offline' } and leaves attempts at 0.
await queue.pause(uploadId);
// uploading | pending → paused
// the in-flight HTTP request is aborted with AbortController
await queue.resume(uploadId);
// paused → pending
// the scheduler decides when it runs; this is not paused → uploading
await queue.cancel(uploadId);
// → cancelled (terminal)
// the original file on disk is not deleted
await queue.retry(uploadId);
// failed | blocked → pending
// the file URI is checked first; missing files throw FileNotFoundErrorIllegal transitions throw InvalidUploadStateError. completed → uploading is rejected.
const unsubscribe = queue.subscribe((event) => {
switch (event.type) {
case 'upload.queued':
case 'upload.started':
case 'upload.progress':
case 'upload.retry_scheduled':
case 'upload.completed':
case 'upload.failed':
case 'upload.paused':
case 'upload.cancelled':
case 'upload.blocked':
break;
}
});Progress events are throttled (default 200 ms). SQLite is updated about every 10% or 500 ms — never per byte.
A listener that throws is swallowed. The queue will not abort a pass because the UI crashed.
The engine talks only to UploadStorage. SQLite is an adapter, not the engine.
import { createMemoryUploadStorage, createUploadQueue } from '@erickmorales91/react-native-upload-queue';
const queue = createUploadQueue({
storage: createMemoryUploadStorage(),
transport: fakeTransport,
});The memory adapter is a honest stand-in: duplicate ids fail, missing updates fail, claims are compare-and-set, and metadata round-trips through JSON — the same contract SQLite implements.
import { createSQLiteUploadStorage, type SQLiteDriver } from '@erickmorales91/react-native-upload-queue';
const storage = createSQLiteUploadStorage({
databaseName: 'uploads.db',
openDriver: async (databaseName) => openYourSqliteDriver(databaseName),
});Or pass a ready driver:
createSQLiteUploadStorage({ driver: myDriver });Required surface:
interface SQLiteDriver {
execute(
sql: string,
params?: readonly unknown[],
): Promise<{ rows: readonly Record<string, unknown>[]; rowsAffected?: number }>;
transaction<T>(fn: (tx: SQLiteDriver) => Promise<T>): Promise<T>;
close?(): Promise<void>;
}Sketch for a native module:
function createQuickSqliteDriver(databaseName: string): SQLiteDriver {
const db = QuickSQLite.open(databaseName);
return {
async execute(sql, params = []) {
const result = db.execute(sql, params as unknown[]);
return {
rows: result.rows?._array ?? [],
rowsAffected: result.rowsAffected,
};
},
async transaction(fn) {
db.execute('BEGIN');
try {
const value = await fn(this);
db.execute('COMMIT');
return value;
} catch (error) {
db.execute('ROLLBACK');
throw error;
}
},
async close() {
db.close();
},
};
}The adapter builds multipart, attaches the idempotency header, reads a fresh token, honors abort/timeout, and returns one status code. It does not loop.
createHttpUploadTransport({
baseUrl: 'https://api.example.com',
getAccessToken: () => auth.getAccessToken(),
idempotencyHeader: 'Idempotency-Key',
fieldName: 'file',
timeoutMs: 60_000,
defaultHeaders: { 'X-Client': 'mobile' },
});On React Native the default body is a { uri, name, type } FormData part. In Node tests it falls back to a Blob. For a custom payload, pass buildBody.
The core never imports NetInfo.
import NetInfo from '@react-native-community/netinfo';
import {
createManualConnectivity,
createNetInfoConnectivityProvider,
} from '@erickmorales91/react-native-upload-queue';
createNetInfoConnectivityProvider({ netInfo: NetInfo });
const connectivity = createManualConnectivity(true);
connectivity.setOnline(false); // tests and “simulate offline” in the example appWhile offline, due work stays pending. After start(), coming back online drains the queue.
| Condition | Kind | Retryable | Result |
|---|---|---|---|
Network / TypeError |
network |
yes | pending + backoff |
408 |
network |
yes | pending |
429 |
rate-limit |
yes | pending, Retry-After wins over local backoff |
500 502 503 504 |
server |
yes | pending |
400 404 409 422 |
validation |
no | failed |
401 |
authentication |
no | blocked |
403 |
authorization |
no | failed |
| Missing file | file-not-found |
no | failed |
| Offline before the attempt | — | — | pending, attempts unchanged |
Default exponential series without jitter: 2s, 4s, 8s, 16s, 32s (capped). Jitter is on by default to avoid a retry stampede.
Retry-After always beats the local strategy.
See docs/retries.md.
createHttpUploadTransport({
baseUrl: 'https://api.example.com',
getAccessToken: async () => auth.getAccessToken(),
});The callback runs on every attempt so an expired token is not reused from the row. In 0.1.0, 401 moves the task to blocked. There is no automatic refresh; that is planned for 0.2.0. The UI should send the user through login and then queue.retry(id).
If the process dies while a row is uploading, it stays uploading on disk.
On the next initialize() / start():
status = uploading
AND processingStartedAt < now - processingTimeoutMs
↓
pending (progress reset; next HTTP attempt starts at byte 0)
A row whose processingStartedAt is 10 seconds old is left alone. Default timeout: 5 minutes.
See docs/recovery.md.
Each task gets a stable idempotencyKey at enqueue time (createId() / UUID). Retries reuse it. The HTTP adapter sends:
Idempotency-Key: <key>The queue cannot make the server idempotent. If the client crashes after the server stored the file but before the client recorded completed, recovery will send the same key again. A correct server returns the original result instead of storing a second object.
Document this with your backend. See docs/idempotency.md.
| Layer | Cadence | Meaning |
|---|---|---|
| UI events | ~200 ms | Paint a bar |
| SQLite | ~10% or 500 ms | Survive a restart with a visual hint |
| Next HTTP attempt | from byte 0 | Unless you add a resumable protocol later |
Do not tell users “resume from 53%” in 0.1.0. Tell them “the upload will be retried”.
react-native-resilient-sync |
react-native-upload-queue |
|---|---|
| JSON domain operations | Files, images, video, PDF |
POST /orders, PUT /users |
Multipart binary transfer |
| Conflict resolvers | Progress, pause, cancel |
| Payload in the row | URI + small metadata, never the bytes |
Use both in the same app when you have notes and attachments. Import neither from the other.
Never persist:
- access / refresh tokens
Authorizationheaders- cookies or passwords
- the multipart body
Store URI, destination, and small structured metadata only. The default logger is a no-op; a custom logger should log uploadId, status, and attempts — not console.log(task).
See SECURITY.md and docs/security.md.
npm test
npm run check:fullThe suite covers the state machine, backoff, HTTP classification, pause/cancel/retry, offline (attempts stay at 0), concurrency (peak === 3 with 20 files), claiming under overlapping process(), and crash recovery (process A dies uploading, process B completes the same idempotency key).
For application tests, inject createMemoryUploadStorage(), createManualConnectivity(), and a fake UploadTransport.
interface UploadQueue {
initialize(): Promise<void>;
enqueue(input: EnqueueUploadInput): Promise<UploadTask>;
start(): Promise<void>;
stop(): Promise<void>;
process(): Promise<UploadProcessResult>;
pause(uploadId: string): Promise<UploadTask>;
resume(uploadId: string): Promise<UploadTask>;
cancel(uploadId: string): Promise<UploadTask>;
retry(uploadId: string): Promise<UploadTask>;
get(uploadId: string): Promise<UploadTask | null>;
list(): Promise<readonly UploadTask[]>;
purgeCompleted(olderThanIso?: string): Promise<number>;
subscribe(listener: (event: UploadQueueEvent) => void): () => void;
destroy(): Promise<void>;
}
type UploadStatus =
| 'pending'
| 'uploading'
| 'paused'
| 'completed'
| 'failed'
| 'blocked'
| 'cancelled';Deeper notes: docs/architecture.md, docs/limitations.md.
0.2.0: auth refresh hooks, priorities, per-upload headers, cleanup policy, queue-level pause/resume.
0.3.0: chunked uploads, persisted chunks, resume tokens, server capability adapter.
MIT. See CONTRIBUTING.md if you are changing behavior.