RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
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.

iCloud3NSUbiquitousKeyValueStoreStoreKit8Rork515indie developer37wallpaper app23

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 $10 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 →