OpenSpace's background HTTP file uploader for React Native. On iOS it uses a
background URLSession; on Android it uses WorkManager (a CoroutineWorker)
with OkHttp. Uploads continue while the app is backgrounded and resume after it
is killed.
Requires React Native ≥ 0.84 with the New Architecture enabled, and React ≥ 19. This is a codegen TurboModule; it does not support the legacy bridge.
yarn add react-native-background-upload
cd ios && pod install && cd ..
pod install is required after installing — it runs codegen to generate the native
spec this module implements.
The package ships TypeScript source with no build step, so it resolves through Metro (and
tsc) but not through plain Node. If you import it from a non-Metro context — a script, or Jest without a transform — add it to yourtransformIgnorePatternsallowlist or mock it.
So uploads that finish while the app is terminated can relaunch it and be
journaled, add this to your AppDelegate:
#import <react_native_background_upload/react_native_background_upload-Swift.h>
- (void)application:(UIApplication *)application
handleEventsForBackgroundURLSession:(NSString *)identifier
completionHandler:(void (^)(void))completionHandler {
[RNBackgroundUpload setBackgroundSessionCompletionHandler:completionHandler
forIdentifier:identifier];
}The Swift header import name is the pod name with hyphens as underscores. If your app links pods as frameworks, use
@import react_native_background_upload;instead of the#import <...-Swift.h>line.
This hook is load-bearing beyond just calling the completion handler: it is what
brings the library's background URLSession back to life in a process the system
relaunched with no JS running, so queued completions get journaled. RNFileUploader
is the TurboModule and is deliberately not reachable from plain Objective-C — its
generated header is Objective-C++ only — so the handler lives on RNBackgroundUpload.
import Upload from 'react-native-background-upload';
// Optional. Call one time at app startup to set the Android notification text.
// The library keeps the text in native storage. Thus a worker relaunched with
// no JS shows the same text. If you do not call configure(), the library uses
// default text and makes its own channel. The call does nothing on iOS.
Upload.configure({ android: { notificationTitle: 'Uploading…' } });
// Listeners are global. Every event carries the upload's id.
Upload.addListener('progress', ({ id, progress }) => {});
// responseCode/responseBody are set for simple uploads only. A chunked
// 'completed' carries neither, because no single response represents N parts.
Upload.addListener('completed', ({ id, responseCode, responseBody }) => {});
Upload.addListener('error', ({ id, error, errorKind, responseCode }) => {});
Upload.addListener('cancelled', ({ id, cancelReason }) => {});
const uploadId = await Upload.startUpload({
type: 'raw',
url: 'https://myservice.com/path/to/post',
path: 'file://path/to/file/on/device',
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
// Optional. Non-2xx responses to treat as success (for example, an
// idempotent create that conflicts). Each other non-2xx response is an
// 'error' with errorKind 'http'.
accept: [{ status: 409, bodyIncludes: 'already completed' }],
});A type: 'chunked' upload sends one file as many part requests but stays one
logical upload: one id, one event stream, byte-weighted progress, and
completed only when every part has been accepted. You author the parts — URL,
headers, byte range — once, at creation; the library owns the transport and
never constructs or edits a protocol field. Author the ranges with chunkPlan
so the part count you tell your server and the parts the library sends derive
from the same array:
const size = (await stat(path)).size;
const ranges = Upload.chunkPlan(size, { min: 8 * 2 ** 20, max: 20 * 2 ** 20 });
// Tell your server ranges.length parts, then:
await Upload.startUpload({
type: 'chunked',
id: myDurableId, // required
path, // see file ownership below
parts: ranges.map((range, i) => ({
url: partUrl(i + 1),
headers: {
Authorization: token,
'Content-Type': 'application/octet-stream',
'Content-Range': `bytes ${range.start}-${range.end - 1}/${size}`,
},
range, // bytes, end exclusive
})),
accept: [{ status: 409, bodyIncludes: 'already completed' }],
expiresAt: Date.now() + 14 * 24 * 60 * 60 * 1000, // required, epoch ms
});File ownership. The library takes the file: an O(1) rename into its own
directory at startUpload. Nothing your app does afterward (cache sweeps,
logout cleanup) can destroy the bytes mid-upload. The file is deleted in
exactly one case — a completed event has been acknowledged via ackEvents.
Copy the file first if you need it afterward.
Resume is re-calling startUpload. The parts are persisted in a native
manifest, so crash recovery, resume after cancelUpload, resume after expiry,
and refreshing auth headers are all the same call: startUpload again with the
same id and the same part ranges/URLs. Parts already accepted are skipped; the
rest continue with the new call's headers and expiresAt (this is how a fresh
token reaches parts that stalled on 401). Once a manifest exists, path is
ignored — the library's owned bytes are the source of truth.
Recreate is the same call with different parts. When the old server upload
is dead (for example, swept server-side), author fresh part URLs and call
startUpload with the same id and the new parts array. The owned bytes are
kept, the parts are replaced, and every part resets to unsent; the new ranges
must tile the same total size. A recreate is accepted only while the upload is
not running — stalled on a terminal error, expired, or cancelled. While it is
running, a differing parts array is rejected: that is a consumer bug, not a
recreate.
Lifetime. Within expiresAt, transient failures (network, 5xx) retry on
exponential backoff with no attempt cap. Past it, the library journals an
error with errorKind: 'expired' and stops — keeping the manifest and bytes,
so you can resume the same server upload with a later expiresAt, or recreate
under a new one. When neither is wanted, release them with removeUpload.
Choosing a value: expiresAt is when your app hears about a stuck upload,
not when data is lost — bytes survive expiry. Pick something well inside your
backend's own cleanup horizon so expiry fires while the server upload is still
resumable, and generous enough for real offline stretches. The OpenSpace
backend prunes incomplete multipart uploads 31 days after creation
(UploadPartCleanup); Diana passes 14 days, leaving a 17-day window where an
expired upload can still resume the same server uploadId.
Terminal events (completed / error / cancelled) are journaled natively
before they are emitted, so they survive app death, JS reloads, and background
relaunches. Events stay in the journal until you acknowledge them. Drain it on
every app start:
const events = await Upload.getUnacknowledgedEvents();
for (const e of events) {
// e: { eventId, id, type, timestamp, responseCode?, responseBody?,
// responseHeaders?, error?, errorKind?, cancelReason? }
handleOutcome(e);
}
await Upload.ackEvents(events.map((e) => e.eventId));
// Then reconcile anything still in flight:
const live = await Upload.getAllUploads(); // [{ id, state, ... }]Notes:
completedfires only for 2xx (or a response matching the request'sacceptrules). Every other HTTP response is anerrorwitherrorKind: 'http'and the response attached — a 400 is an error, not a completion.errorKindis'http' | 'network' | 'file' | 'expired' | 'unknown'. Retry transport failures; treat client errors as terminal;expiredmeans a chunked upload'sexpiresAtpassed (see Chunked uploads for recovery).cancelReasondistinguishes a user cancel ('user') from a system kill ('system').- Duplicate journal entries for one upload id are possible if the process dies at
the wrong moment (Android may re-run the worker) — dedupe by
id, keep latest. - Android:
getAllUploads()reflects only live/recent work (WorkManager prunes finished work after ~a day). The journal is the source of truth for outcomes.
All methods are on the default export.
One-time setup — call at app startup. options.android sets the upload
notification's text and identity:
notificationId/Title/TitleNoWifi/TitleNoInternet/Channel. The config is
persisted natively, so a worker relaunched by WorkManager with no JS running
shows the same text. Optional: omitted fields keep the library defaults (each
call replaces the whole config). A no-op on iOS, which has no library
notification.
Starts an upload; resolves to its id. Discriminated on options.type: 'raw'
sends the whole file as one request body, 'chunked' sends the authored parts
(see Chunked uploads). Rejects (or, for malformed chunked input, throws
synchronously) only on bad options — transport failures and HTTP error responses
arrive later as error events, not a rejection.
Idempotent for every upload, always. Calling startUpload again with an id
that is already pending or running is never an error: a raw upload resolves with
the same id instead of starting a duplicate; a chunked upload reconciles — parts
already accepted are skipped, the rest continue with the new call's headers. No
pre-dispatch dedupe is needed on your side.
Options for type: 'raw':
| Option | Type | Notes |
|---|---|---|
url |
string | Required. |
path |
string | Required. Local file path (file://…). URIs are not escaped for you. |
method |
string | Default POST. |
headers |
object | HTTP headers. |
id |
string | Defaults to a generated UUID. |
wifiOnly |
boolean | Wait for wifi before/while uploading. |
accept |
AcceptRule[] | Non-2xx responses to treat as success — see Accept rules. |
android |
object | Optional. noNotification (default false) — see Silent uploads. Notification text is set once via configure(), not per upload. |
Options for type: 'chunked':
| Option | Type | Notes |
|---|---|---|
id |
string | Required — your durable id. |
path |
string | Required. The library takes ownership of the file — see Chunked uploads. |
parts |
array | Required. { url, headers, range: { start, end } } per part; ranges in bytes, end exclusive. Sent verbatim as PUTs. |
expiresAt |
number | Required, epoch ms. Past it: terminal error with errorKind: 'expired'. |
accept |
AcceptRule[] | See Accept rules. |
wifiOnly |
boolean | Wait for wifi before/while uploading. |
android |
object | Same as raw. |
accept: Array<{ status: number, bodyIncludes?: string }> — non-2xx responses
to treat as success, for both upload types. bodyIncludes narrows a rule by
response-body substring, for servers where one status carries several meanings
distinguishable only by message. A response matching a rule completes the
request (for chunked, marks the part accepted); any other non-2xx is an error
with errorKind: 'http'.
android: { noNotification: true } uploads a file without posting a progress
notification, so the shade only shows the uploads a user actually asked to watch.
That notification is also the worker's foreground-service notification, so a
silent upload runs as an ordinary background worker instead. The OS is then free
to defer it, or to stop it mid-flight and let WorkManager re-run it later. Keep
the notification for anything that takes real time to upload; reserve
noNotification for small payloads a restart would cost nothing.
All uploads share one notification (identified by the configured
notificationId), and its progress bar reports every in-flight upload — silent
ones included.
Cancels an upload. Fires a cancelled event with cancelReason: 'user'. For a
chunked upload this cancels in-flight requests but keeps the manifest and bytes —
the next startUpload with the same id resumes it (there is no separate pause
API).
Releases an upload's native manifest and bytes. Every terminal outcome other
than an acked completed (expired, error, cancelled) keeps both so you can
resume or recreate; call this once neither is wanted.
Splits a byte count into contiguous, end-exclusive ranges: a deterministic
greedy walk of max-sized chunks (default 20MB), with a final remainder smaller
than min (default 8MB) absorbed into the previous chunk. A file smaller than
min is a single chunk. Pure and deterministic on purpose: call it once and
derive both your server's part count and the parts array from the same result,
so the two can never disagree.
addListener(event: 'progress' | 'error' | 'completed' | 'cancelled', callback).
Listeners are global — there is no per-upload subscription; every event carries
the upload's id, so discriminate on it. Call .remove() on the result to
unsubscribe.
Terminal events not yet acknowledged, including ones that fired while JS was dead.
Removes journaled events once processed.
Uploads the OS still knows about, for boot-time reconciliation.
Fires when the Android progress notification is pressed. No event data.
| Event | Data |
|---|---|
progress |
{ id, progress: 0-100 } |
completed |
{ id, responseCode?, responseBody?, responseHeaders?, eventId? } — response fields on simple uploads only; a chunked completed carries none (no single response represents N parts) |
error |
{ id, error, errorKind?, partIndex?, responseCode?, responseBody?, responseHeaders? } |
cancelled |
`{ id, cancelReason?: 'user' |
See CONTRIBUTING.md.