Troubleshooting
"The native module doesn't seem to be linked"
Run pod install and rebuild - restarting Metro isn't enough. Expo Go can't load custom native modules; you need a development build.
Writes appear to work but nothing reaches iCloud
Check getAccountStatus() first - a signed-out device accepts writes locally but never uploads them, and nothing failed, so there's no error to catch.
const status = await icloudKV.getAccountStatus()
if (status !== 'available') promptSignIn(status)
ERR_CONTAINER_MISCONFIGURED
Either the app has no iCloud entitlement, or the container identifier in the entitlements file doesn't match the container that exists in the CloudKit Console.
getAllKeys() fails on CloudKit, but reads and writes work
The record type has no queryable index. Add a Queryable index on recordName for KVBlob in the CloudKit Console, and deploy it to Production - see the schema. migrate() and any "delete everything" flow are built on getAllKeys(), so they fail with it too.
CloudKit works in debug and fails in the release build
Development creates schema fields on first write; Production only ever gets what you explicitly Deploy Schema Changes. A field you never deployed does not exist there. See the schema.
Nothing appears on the other device
Same root cause as above (separate datastores) - also confirm both devices are on the same iCloud account, and that the schema record type exists in the environment you are pointing at.
Android CloudKit stops working after a while
Expected - see the constraint. Handle ERR_AUTH_EXPIRED by prompting for sign-in again rather than retrying.
If it stops after exactly one request, that is a different problem - check that the token is being persisted rather than re-read from an empty store.
The Apple ID sign-in WebView does nothing on Android
Two likely causes:
- The Sign In Callback in the CloudKit Console doesn't exactly match the scheme you intercept. Use
cloudkit-<container-id>://callback. - You're intercepting with
onShouldStartLoadWithRequest, which isn't dependable for Android's server-side 302 redirects. Intercept at the native page-load level instead.
Queued writes disappear when the app restarts
The outbox defaults to in-memory. Pass outboxStorage backed by MMKV, or by any store with a synchronous getString - see the outbox for why AsyncStorage needs a cache-in-front wrapper instead.
getItem returns null but the data definitely exists
null means the key does not exist in the providers you configured. Two things to check:
- Provider order.
getItemfalls through the list, but only through providers that reportisAvailable(). - Key mismatch. Drive keys are file names; CloudKit keys are record names. They are not automatically the same string unless you make them so.
Reads are slow on Google Drive
See Performance notes - ids are cached after the first lookup, but a cold start pays one query per key. Reading many keys at launch? Prefer one larger blob over many small ones.
An oversized write fails locally
Deliberate: ERR_PAYLOAD_TOO_LARGE carries limitBytes and actualBytes, raised before the request goes out. Move that key to a larger-capacity provider, or let the facade route it by size.
Everything works on iOS and nothing works on web
icloudKV has no browser implementation and rejects with ERR_UNSUPPORTED_PLATFORM. Use isAvailable() to branch, or route through the facade with googleDrive in the provider list.
ERR_INVALID_KEY on a key that used to work
Key validation now runs before the request. One key string has to be an NSUbiquitousKeyValueStore key, a CloudKit recordName and a Drive filename at once, and the three disagree - settings/theme is fine for the first two and illegal as a record name.
Rename the key, run it through sanitizeKey, or pass validateKeys: false if you are certain.
getItem throws ERR_NOT_SIGNED_IN where it used to return null
Intentional - see absent vs broken. null now means only "at least one provider answered and none had this key"; when nothing was reachable you get an error instead.
If you genuinely want the old behaviour at one call site, catch it:
const value = await store.getItem(key).catch(() => null)
Think about what that call site does with null first.
Queued writes vanish after the user switches account
Also intentional - see account switches: an outbox entry carries no account identity, so it's dropped on identityChanged rather than flushed into the new account.
Write local-first if those writes need to survive: keep the value in MMKV, and re-queue it against the new account if it still belongs there.
A transfer hangs forever
React Native's fetch has no timeout of its own - the REST clients default to 30s (CloudKit) and 60s (Drive), and createCloudStore({ timeoutMs }) bounds native calls too.
ERR_TIMEOUT means the wait was abandoned, not that the operation failed - it may still be in flight, which is why it is retryable.
The iCloud Drive folder does not appear in Files.app
Entitlements alone get files syncing but leave the folder invisible - see iCloud Drive setup for the Info.plist entry (and the CFBundleShortVersionString caching gotcha) that fixes it.
An iCloud Drive file reads as empty
It's a placeholder: the file exists in the account but has no local bytes on this device, so opening the path gives you nothing rather than an error. Call icloudDocuments.fetch({ name }) first - see iCloud Drive.