Storage, network and testing
The two pluggable parts of the queue, how payload upgrades work, how to run several queues, and how to test everything.
Storage
The queue persists jobs through a tiny synchronous interface:
A react-native-mmkv instance already matches it, so you pass it directly.
Synchronous is the important word: it lets enqueue() guarantee the job is saved
before it returns, and lets the queue load everything at creation with no async start-up.
MMKV options
Sharing an instance with other app data works too: the queue only reads keys that start with its own prefix and ignores everything else.
What is stored
Each job is one key, queuekit:<queue name>:job:<id>, holding the job as JSON.
Updating a job rewrites only that key.
Secrets and tokens
Everything in a payload, tokens included, is saved as plain text in the storage you pass in.
It stays there while the job waits, retries or is offline, and after a restart. A completed
job is deleted; a failed one keeps its payload until you retry or remove it, or
retention cleans it up.
The best fix is to keep the secret out of the payload and read it when the handler runs. The job then holds nothing sensitive, and a token that expired while the job waited is never reused.
If a payload really must hold something private:
| Tool | What it does | What it does not do |
|---|---|---|
MMKV encryptionKey | Encrypts the data on disk. | Protect the payload in memory, or from your own event listeners. |
sensitive: true | Hides the payload and handler result from on() and observe(), so it cannot reach logs or analytics. | Encrypt anything. It is still stored as plain JSON unless the storage is encrypted. |
| Development warning | Logs once when a payload key contains token, password, secret or authorization. | Run in production builds. |
Use both together, and keep the encryption key in the Keychain or Keystore, not in source
code. queue.getJobs() and the job returned by enqueue() still show
the payload, and error messages in lastError are not redacted.
Other backends
Its API is asynchronous. A queue on top of it could not promise that a job is saved when
enqueue() returns, so QueueKit does not support it. Any store with the four
synchronous methods above does.
Network monitors
alwaysOnline: the default. Always online, never emits.-
netInfoMonitor(NetInfo): adapts@react-native-community/netinfo. Online unless NetInfo reportsisConnected === falseorisInternetReachable === false. Assumes online until the first event (NetInfo emits one immediately on subscribe). - Anything else: implement the two methods.
Check that your server is really reachable
Wi-Fi without internet (hotels, captive portals) reports as connected. If that matters, probe your own API:
Require several signals at once
A manual switch for QA
Migrations
Jobs outlive app updates. If you change a payload's shape, jobs saved by the previous version still carry the old one. A migration upgrades them once, when the queue loads.
| Situation | Result |
|---|---|
| Saved job has no version | Treated as version 1. |
Saved version is lower than version | migrate(payload, savedVersion) runs once. The result and the new version are saved. |
| Saved version is the same or newer | Left untouched. |
migrate throws | That job becomes failed with lastError "Migration failed: ...". Other jobs are unaffected. |
| A type with no migration | Untouched. |
| New jobs | Stamped with the current version. |
Renaming a job type
Migrations change payloads, not names. If you rename a type, jobs saved under the old name have no handler and would wait forever. Keep an alias handler for the old name:
Multiple queues
Each createQueue call is an independent queue with its own concurrency,
timeout, retry policy and limits. Queues are separated in storage by
name, so they can safely share one MMKV instance.
When to split: different timeouts, different failure tolerance, or you do
not want bulk work to share concurrency slots with user-facing work. For simple prioritization,
one queue with priority is enough.
QueueProvider holds one queue. For others, nest providers (the inner one wins
for its subtree), or use useSyncExternalStore(analytics.subscribe, analytics.getStats)
directly.
Several instances sharing one storage
Normally an app has exactly one JavaScript runtime and one queue, and none of this applies.
It matters when two instances can exist over the same storage and name: a
second JS context in the same process, an app extension, or a development reload that left
the old queue running. Each instance would load the same jobs and run them, so they run
twice.
The lease option lets only one instance, the leader, run jobs at a time.
| Situation | What happens |
|---|---|
| First instance starts | It takes the lease and leads. Later instances become followers. |
A follower calls enqueue() | The job is saved. The leader finds it on its next heartbeat (every ttlMs / 3) and runs it. Until then the leader does not know about it, so orderKey order across instances is best effort. |
| The leader stops while idle | It releases the lease. A follower takes over on its next heartbeat, or immediately on flush(). |
| The leader stops with a job still running | It keeps renewing the lease until that job ends, then releases it, so the job is never run twice. |
| The leader dies | Its lease expires after ttlMs. A follower takes over, treats the jobs the dead leader was running as interrupted, and runs them again. |
drain() on a follower | Resolves true at once: this instance has nothing to run. |
| Followers' view of the jobs | Refreshed from storage every ttlMs / 3, so hooks stay roughly current. |
- Storage has no atomic compare-and-set. Two instances acquiring in the same instant can both believe they won. This makes double runs very unlikely, not impossible: keep handlers idempotent.
- Change existing jobs (
remove,retry,clear,complete,fail) on the leader. A follower's changes to jobs the leader already holds in memory are not merged. - Off by default. It costs one small storage write per heartbeat.
- It does not fix Fast Refresh: the old queue keeps renewing the lease, so the new one stays a follower. Dispose the old instance (FAQ).
Signing out and switching users
Jobs often belong to one account. Pick a strategy:
Do not leave jobs from user A runnable under user B's session: handlers read the current token when they run, so they would act as the wrong user.
Testing
Everything is plain objects and promises, so tests need no mocks of the queue itself. Use
memoryStorage(), a fake network, and await queue.drain().
Offline behaviour
Persistence and restarts
Tips
- Use
retry: { baseDelayMs: 1, jitter: false }so retries take milliseconds, not seconds. - Export the handler as a plain function next to the job, so you can unit test it without a queue at all.
- Use
autoStart: falseandqueue.start()when you need to enqueue several jobs before anything runs, for example to test ordering. - Fake timers work for delays and backoff, but advance them asynchronously (
vi.advanceTimersByTimeAsync) so promise callbacks run. - Call
queue.dispose()inafterEachif a test leaves jobs running.