Background and native tasks
A JavaScript queue only runs while JavaScript runs. This page explains what that means on a phone, and the three layers QueueKit gives you to make jobs finish anyway.
What actually runs when
| App state | JavaScript | The queue |
|---|---|---|
| Foreground | Runs | Runs normally. |
| Just backgrounded | The OS usually allows a short grace period (seconds) | Jobs in flight may finish. New ones may not get time. |
| Suspended in the background | Frozen. Timers do not fire. | Paused. Backoff timers resume when the app wakes. |
| Woken by the OS for a background task | Runs for a short window you do not control | You can drain() inside it. |
| Terminated by the system | Not running | Jobs are safe in storage and continue on next launch (or next background wake). |
| Force-quit by the user | Not running. iOS generally will not wake the app for background tasks until the user opens it again. | Waits for next launch. |
iOS and Android decide when, and whether, background work runs, based on battery, usage patterns, Low Power Mode, Doze and vendor-specific rules. Treat everything in layers 2 and 3 as "helps in practice", never as a guarantee. Layer 1 and the native tasks in layer 4 are the dependable ones.
Layer 1: catch up when the app returns
While the app was suspended, timers did not fire and connectivity may have changed without
the queue noticing. flush() re-reads connectivity and starts whatever is due.
It calls queue.flush() every time the app state becomes "active". It returns a function that stops listening.
Layer 2: periodic background tasks
Both platforms can wake your app now and then to do a little work. Inside that window,
await queue.drain({ timeoutMs }) runs everything it can and returns when the queue
is idle or when your time budget is used up.
These snippets show the pattern; check each library's current documentation for exact option names and platform setup.
Rules of thumb
- Always pass
timeoutMs. Finish a few seconds before the OS ends your window.drainreturnsfalseif time ran out. - Import your job modules in the task file. The task may run without your UI, so a handler that was only registered by a screen will be missing and its jobs will not run.
- Handlers must be idempotent. A job still running when the OS kills the process runs again later.
drainignores jobs that cannot run (offline, no handler, detached, due after your timeout), so it will not hold the window open for them.- Do not expect an exact schedule. Fifteen minutes is the minimum, not a promise.
Layer 3: wake on a silent push
A silent (data-only) push notification can wake the app briefly on both platforms, again best effort. Use the same pattern in its handler:
Use it to nudge devices to flush after something changed on your server, for example a new token or a "please sync" signal.
Layer 4: native tasks with detach
For large uploads and downloads, do not run the transfer in JavaScript at all. Hand it to the OS's own transfer service, which keeps going while the app is suspended and can finish even if the app is terminated. QueueKit keeps tracking the job.
The flow
- The handler starts the native transfer and gets a task id.
- It returns
ctx.detach(taskId). The job staysactivewithjob.externalId = taskId, frees its concurrency slot and has no timeout. - When the native task ends, you call
queue.complete(jobId)orqueue.fail(jobId, error). fail()goes through the normal retry logic: the handler runs again (a fresh upload) or the job becomesfailed.- After an app restart, detached jobs are still
active. At startup, look them up and reattach listeners.
NativeUploader stands for whatever uploader you use
(react-native-background-upload, a native module of your own, and so on). The
pattern is the same: start, detach, report back.
Rules for detach
| Topic | Behaviour |
|---|---|
| Where to call it | return ctx.detach(id) from a normal or async handler. It does not work from a generator handler. |
| Slot and timeout | The job releases its concurrency slot and has no timeout while detached. |
| Status | active, with externalId set. progress is cleared when a new attempt starts. |
| Restart | Stays active, not re-run. You reattach. |
complete(id) | Deletes the job and emits completed. Returns false if the job is unknown or not detached. |
fail(id, error?) | Retries or fails the job like a thrown error would. The default message is "External task failed". |
remove(id) | Deletes the job. You must also cancel the native task. |
drain() | Does not wait for detached jobs. |
| Analytics | durationMs is not reported for jobs finished by complete() / fail(). |
If the OS completed the transfer while your app was not running, nothing tells the queue.
Make sure your uploader reports pending results on next launch, or check your backend
for the object by its idempotency key, then call complete() or
fail(). Do this inside reattachUploads().
Cancelling a detached upload
A sensible combination
- Always:
flushOnForeground(queue, AppState). - Small jobs (API calls, forms): rely on the queue plus a periodic background
drain. - Big files:
detach()to a native uploader. - Make every handler idempotent, and always send
job.idas an idempotency key. - Test with the app killed from the app switcher and with airplane mode toggled.