RORK LABJP
PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updatesPLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
Articles/Dev Tools
Dev Tools/2026-05-28Intermediate

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.

iCloud3NSUbiquitousKeyValueStoreStoreKit8Rork547indie developer39wallpaper app22

Premium Article

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 ExpoModulesCore
import Foundation
 
public 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:

import { NativeModule, requireNativeModule } from 'expo-modules-core';
 
type ChangeEvent = { reason: number; keys: string[] };
 
declare class ICloudKVSyncModule extends NativeModule<{
  onRemoteChange: (event: ChangeEvent) => void;
}> {
  setString(key: string, value: string): Promise<boolean>;
  getString(key: string): Promise<string | null>;
  removeKey(key: string): Promise<boolean>;
  synchronize(): Promise<boolean>;
}
 
export default requireNativeModule<ICloudKVSyncModule>('ICloudKVSync');

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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

Related Articles

Dev Tools2026-05-28
Three Weeks of Moving Six Wallpaper Apps from AsyncStorage to MMKV in Rork
Notes from three weeks of gradually moving six wallpaper apps from AsyncStorage to react-native-mmkv. Personal write-up from an indie developer who has been shipping iOS and Android apps since 2014.
Dev Tools2026-05-26
Two Weeks Tightening Up iPad Support for a Rork-Generated Wallpaper App
Notes from spending two weeks tightening up iPad support for a wallpaper app I scaffolded with Rork. Coming from an iPhone-centric indie practice since 2014, I cover where Rork's defaults stopped, how the AdMob adaptive banner misbehaved on iPad, and what changed in retention afterwards.
Dev Tools2026-07-04
Should You Show a Read More Link? Let the Rendered Text Decide in Rork (Expo)
Clamping a product description to three lines and adding a Read more toggle sounds simple, until the toggle also appears under single-line text. This walks through measuring the real line count with onTextLayout so the toggle only shows when text actually overflows, covering iOS vs Android quirks, expand animation, and font scaling.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →