●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Syncing 'Favorite Wallpapers' Across Devices with NSUbiquitousKeyValueStore in Rork iOS Apps — Implementation Notes from Six Apps Run in Parallel
For Rork-generated iOS apps, syncing a small set of favorites across devices is often better served by NSUbiquitousKeyValueStore than CloudKit. From the perspective of running six wallpaper apps in parallel, this article shares the threshold design, conflict resolution, and first-launch restore order learned in production.
A bad week stuck with me — two reviews in a row complained that "all my favorite wallpapers disappeared after switching phones." That was the trigger to move favorites out of AsyncStorage and into iCloud for all six of my wallpaper apps. I'm Hirokawa, an indie developer and contemporary artist. I've been shipping iOS and Android apps as a personal developer since 2014, and the wallpaper apps in question have accumulated over 50 million downloads in total.
When people hear "sync via iCloud," they reach for CloudKit by reflex. But for a small list of favorite wallpapers, CloudKit was overkill. What I actually chose was NSUbiquitousKeyValueStore (the key-value store, or KV store from here on). This article is an implementation note from three weeks of running the new setup in production across all six apps after adding the KV store to a Rork-generated Expo project. I've recorded the judgment calls — and the numbers behind them — that helped me avoid the common pitfall of "sample code that works but breaks in production."
Why NSUbiquitousKeyValueStore Instead of CloudKit
The data model for "favorite wallpapers" boils down to "an array of wallpaper IDs" plus a few per-category notes. Putting that on CloudKit means schema management, container setup, and subscription wiring — repeated for each of six apps. There was another big reason in my case: CloudKit can grow into per-container quotas and operational overhead, and for an indie developer whose revenue centers on AdMob, raising maintenance cost has to come with a strong justification. It didn't here.
NSUbiquitousKeyValueStore is a simple key-value store tied to the user's Apple ID, capped at 1 MB total, 1024 keys, and 1 MB per value. Storing wallpaper IDs as strings, a typical 180-item favorites list weighs around 5 KB. With six apps each running an independent KV store under the user's Apple ID, the capacity was plenty.
Boiling the choice down to a rule of thumb:
Structured per-user data (comments, edit history, large media) → CloudKit
"A small piece of state I want consistent across devices" → KV store
State that needs to be shared between users → CloudKit public DB or a custom backend
Favorite wallpapers belong in the second bucket. Forcing them onto CloudKit turns one design question into three (billing, ops, update propagation), which collapses six-app parallel maintenance. The reasons I chose the KV store for wallpaper apps were "I'm confident it fits in a small footprint," and just as importantly, "I want to outsource as much backend responsibility to Apple as I reasonably can."
The Shortest Path to Adding a KV Store to an Expo / React Native Project
Rork's Expo output doesn't ship a native module for the KV store out of the box. Writing a thin Swift bridge with expo-modules-core turned out to be the most maintainable shape — and the one I could reuse across all six apps.
Place a file at modules/iCloudKVSync/ios/ICloudKVSync.swift with the following:
import ExpoModulesCoreimport Foundationpublic class ICloudKVSyncModule: Module { private var observer: NSObjectProtocol? public func definition() -> ModuleDefinition { Name("ICloudKVSync") Events("onRemoteChange") OnCreate { let store = NSUbiquitousKeyValueStore.default // Best-effort first synchronize — log only on failure _ = store.synchronize() self.observer = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: store, queue: .main ) { [weak self] notification in guard let self = self else { return } let reason = notification.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int ?? -1 let keys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] ?? [] self.sendEvent("onRemoteChange", [ "reason": reason, "keys": keys ]) } } OnDestroy { if let observer = self.observer { NotificationCenter.default.removeObserver(observer) } } AsyncFunction("setString") { (key: String, value: String) -> Bool in let store = NSUbiquitousKeyValueStore.default store.set(value, forKey: key) return store.synchronize() } AsyncFunction("getString") { (key: String) -> String? in return NSUbiquitousKeyValueStore.default.string(forKey: key) } AsyncFunction("removeKey") { (key: String) -> Bool in let store = NSUbiquitousKeyValueStore.default store.removeObject(forKey: key) return store.synchronize() } AsyncFunction("synchronize") { () -> Bool in return NSUbiquitousKeyValueStore.default.synchronize() } }}
Three things to highlight. First, the return value of synchronize() only means "the local cache write succeeded" — it does not signal propagation completion to the cloud. Second, subscribing to didChangeExternallyNotification and forwarding the reason / changed keys to JS leaves room to layer conflict resolution later. Third, you must release the observer in OnDestroy, or hot-reloading the Expo dev build will fire multiple handlers.
Keep the JS side type-thin and confine direct store access to one file:
Also add com.apple.developer.ubiquity-kvstore-identifier to ios.entitlements in app.json. If you forget this, Xcode builds will compile fine but the production TestFlight build silently no-ops every read and write — a quiet, hard-to-spot bug.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How to keep ~180 favorites per user in sync across six wallpaper apps within the 1MB / 1024-key limit
✦The cost calculus that pushed me away from CloudKit toward NSUbiquitousKeyValueStore
✦A last-writer-wins conflict resolution that tolerates clock drift between devices
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
A first sketch packed the whole favorites list into one JSON string under a single key. I dropped that after three days. Two reasons. One, with concurrent edits across devices, "the last writer wins" silently erases the other side's additions. Two, JSON close to the 1 MB ceiling noticeably slows down propagation — Apple doesn't document this explicitly, but I've watched the same pattern across multiple apps.
What I settled on is splitting keys at a meaningful granularity:
Key
Role
Example value
fav:v
Schema version
"3"
fav:idx
Sorted array of all IDs (JSON)
["wp_8a", "wp_19", ...]
fav:ts:
Last-modified UNIX timestamp per favorite
"1716879100"
fav:meta:
Optional metadata (category, tags)
JSON string
fav:tomb:
Tombstone marker with TTL
"1716879500"
This granularity gives you three wins: (a) a single add or remove takes only two key updates, (b) keeping tombstones in their own keys prevents the "deleted on one device, immediately resurrected on another" bug, and (c) the index array alone is enough to render the UI instantly. Apple syncs key-by-key in the KV store, so this is visibly faster than shoving the whole JSON across each time.
A note on deletes. Removing a favorite drops the ID from fav:idx and writes a "14 days from now" UNIX timestamp into fav:tomb:<id>. When a remote index arrives and contains an ID whose local tombstone is still alive, ignore the addition and re-propagate the delete back. A startup GC sweeps tombstones older than 14 days. The net effect: the most recent intent-to-delete wins, reliably.
The KV store guarantees that "the last device to write wins," but only at the individual-key level. Merging an array like fav:idx is your responsibility.
Here are the merge rules I landed on:
On receiving a remote fav:idx, diff it against the local fav:idx.
IDs that exist remotely but not locally: ignore if fav:tomb:<id> is still alive (it was deleted here), otherwise treat as an addition.
IDs that exist locally but not remotely: if local fav:ts:<id> is newer than the remote-receive time, treat as an addition; otherwise treat as a deletion.
Once resolved, write the new fav:idx immediately to propagate.
Wrap this in a React hook so the UI code only calls "add" or "remove" and gets sync for free.
This hook loads once at startup and re-merges on remote change events. The UI subscribes to state.ids and never needs to know the KV store exists.
First-Launch Restore Order — Coexisting with AsyncStorage During Migration
Dropping the AsyncStorage favorites you've shipped for years is risky. I ran a one-month coexistence phase. On startup, the restore order is:
Read the "most recent favorites snapshot" from AsyncStorage and render the UI immediately.
Read fav:idx from the KV store. If empty, upload the AsyncStorage snapshot to the KV store.
When both exist, walk fav:ts:<id> to pick the newer per-ID value.
Write the merged result back to AsyncStorage to keep them aligned.
The reason I refused to make the KV store the sole source of truth: devices with temporarily disabled iCloud sync (long airplane mode, full iCloud quota, signed-out states) shouldn't see an empty UI. Treating AsyncStorage as a "perpetually updated offline cache" keeps UI restore independent of network conditions.
In practice, the first merge often runs into "KV store empty, AsyncStorage full" cases. For those, batching fav:idx writes into 50-item chunks every 500 ms turned out to be more stable than pushing everything at once. Apple appears to batch KV store updates internally with thresholds that aren't documented. I run the same interval across all six apps.
Observing Failure — Catching Silent Degradation That Crashlytics Misses
"My favorites disappeared" never shows up in Crashlytics because nothing crashes. Without separate observability, you only learn about it from one-star reviews.
The four observation points I built:
KV store write count and average size (aggregated locally for a week, then sent as telemetry)
Distribution of NSUbiquitousKeyValueStoreChangeReasonKey values on didChangeExternallyNotification
Fraction of launches with no signed-in Apple ID (FileManager.default.ubiquityIdentityToken is nil)
Quartiles of the diff between AsyncStorage and KV store
The third metric is especially important — about 8% of users aren't signed in to iCloud. For that segment, I show a one-time onboarding card: "Sign in to iCloud to sync across devices." Not pushy, but not silent either. That balance feels like the minimum responsibility I owe to users who'd otherwise miss the sync risk.
For seriously chasing degradation that Crashlytics can't see, pairing it with Sentry or another lightweight telemetry tool is pragmatic. Across the six wallpaper apps I keep "favorites-operation success rate" pinned to a daily band of 99.94% to 99.98%. Three days under 99.9% triggers either a rollback or a same-day investigation thread.
The Win of Killing the Backend — And Where It Still Doesn't Pay Off
The largest gain from moving to the KV store was that backend operations disappeared entirely. Previously I ran favorites sync on Cloudflare D1 plus Workers — small dollar cost monthly, but a real mental load when something broke. Pushing it to Apple folds that cost into the Apple Developer Program annual fee.
That said, I'm clear about where the KV store doesn't fit:
Search index over favorites (category/tag crossover) → local SQLite
Sharing entitlements across sibling apps → App Groups UserDefaults plus a custom API
Per-user announcements from the operator → Firebase Remote Config
I didn't write off CloudKit — I picked the smallest tool for the narrow problem of "syncing favorite wallpapers." When I first dialed into the internet in 1997 and taught myself to code, I picked up a habit: among the options that work, take the smallest one. I keep recalling that instinct whenever a design decision shows up.
Numbers After One Month, and How It Felt
From late March into April, I rolled out KV store sync across the six wallpaper apps in sequence. Numbers from the first month of operation:
"Favorites didn't restore after switching phones" reviews dropped from about 5 per month to 0–1.
Average size per KV store write hovered around 320 bytes.
Median cross-device sync delay was 4 seconds on Wi-Fi and 11 seconds on mobile.
The share of users signed out of iCloud held steady at 8%.
Total code added across the six apps was around 380 lines, of which 110 were the native module.
It wasn't a "dramatically transformed" feeling. It was more like "quietly stabilized in a place users would never notice." Small preference data — favorite wallpapers, in this case — turns into anger when it disappears unseen, so handing the infrastructure for "silently and reliably syncing" to Apple has steadily lowered the mental tax of six-app parallel maintenance.
I'll reach for CloudKit when structured data or cross-user sharing actually shows up. Until then, the line stays where I drew it: "KV store covers what KV store covers; everything else stays mostly local." If you're juggling several apps too, the first move I'd recommend is writing down the state you want to sync, then checking whether each item truly justifies CloudKit's schema-management cost. Most of the time, more of it fits in the KV store than you'd guess.
Thanks for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.