<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[After Hours]]></title><description><![CDATA[An engineering manager in Tokyo building apps and games on the side. Notes on Swift, AI, game dev, mobile, and growing indie projects.]]></description><link>https://timefrancesco.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/56b143f70a7ca0c6f70c396c/08ed19e1-4794-460f-9639-ca8e060a0f73.png</url><title>After Hours</title><link>https://timefrancesco.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:18:39 GMT</lastBuildDate><atom:link href="https://timefrancesco.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How NewsNinja syncs all your data in less than 1 MB of iCloud]]></title><description><![CDATA[How NewsNinja syncs feeds, settings, read and starred articles across devices with NSUbiquitousKeyValueStore, and the tricks to stay under 1 MB.]]></description><link>https://timefrancesco.hashnode.dev/icloud-key-value-store-sync</link><guid isPermaLink="true">https://timefrancesco.hashnode.dev/icloud-key-value-store-sync</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[macOS]]></category><dc:creator><![CDATA[Francesco Pretelli]]></dc:creator><pubDate>Wed, 11 Nov 2026 12:16:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/56b143f70a7ca0c6f70c396c/d8070921-4442-4334-b3b1-f7f7e668b11e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://ainews.ninja"><em>NewsNinja</em></a> <em>is an RSS reader for iPhone, iPad, Mac, and Vision Pro that I build after hours. This post was originally published on the</em> <a href="https://ainews.ninja/blog/icloud-key-value-store-sync/"><em>NewsNinja blog</em></a><em>.</em></p>
<p>NewsNinja has no account and no sync server, but it still syncs between your iPhone, iPad, Vision Pro, and Mac. Your feeds, categories, settings, read articles, and starred articles all go through a single, small Apple API: <code>NSUbiquitousKeyValueStore</code>, the iCloud key-value store.</p>
<p>The whole thing fits in <strong>less than 1 MB</strong>. This post explains how the store works, why I picked it, and the tricks that keep NewsNinja inside its limits as the app has grown.</p>
<h2>It started with a list of URLs</h2>
<p>The first version of NewsNinja was a very simple RSS reader. The only thing that needed to sync was the list of feeds you subscribed to, and a feed list is just a list of URLs.</p>
<p>For that, a real database felt like overkill. Core Data or SwiftData would have meant a schema, a model container, migrations, and a CloudKit container to set up and maintain, all to store an array of strings. I didn't want to run my own sync server either. NewsNinja doesn't have accounts, and I wanted to keep it that way.</p>
<p><code>NSUbiquitousKeyValueStore</code> was exactly the right size for the problem.</p>
<h2>How NSUbiquitousKeyValueStore works</h2>
<p>If you know <code>UserDefaults</code>, you already know most of the API. It's a dictionary of keys and property-list values (strings, numbers, booleans, dates, <code>Data</code>, arrays, and dictionaries) that the system syncs through the user's iCloud account.</p>
<p>To use it, enable the <strong>iCloud</strong> capability in Xcode and tick <strong>Key-value storage</strong>. This adds the <code>com.apple.developer.ubiquity-kvstore-identifier</code> entitlement. No CloudKit container or schema needed.</p>
<p>This is close to the first <code>KeyValueStore</code> that shipped in NewsNinja, back in June 2024:</p>
<pre><code class="language-swift">import Foundation
import Combine

class KeyValueStore: ObservableObject {
    static let shared = KeyValueStore()

    private let keyValueStore = NSUbiquitousKeyValueStore.default
    private let rssFeedsKey = "rssFeeds"
    private var cancellable: AnyCancellable?

    @Published var rssFeeds: [String] {
        didSet {
            keyValueStore.set(rssFeeds, forKey: rssFeedsKey)
            keyValueStore.synchronize()
        }
    }

    private init() {
        rssFeeds = keyValueStore.array(forKey: rssFeedsKey) as? [String] ?? []
    }

    func addRssFeed(_ feedUrl: String) {
        guard !rssFeeds.contains(feedUrl) else { return }
        rssFeeds.append(feedUrl)
    }
}
</code></pre>
<p>That's the whole persistence layer: one key, one array of strings. Reading is synchronous and writing is instant, so SwiftUI views can bind to it like any other <code>@Published</code> property.</p>
<h3>Writes are local first</h3>
<p>When you call <code>set(_:forKey:)</code>, the value is written to a local copy on the device right away. The system uploads it to iCloud later, at a time it chooses. <code>synchronize()</code> doesn't force an upload. As Apple's documentation puts it, it only "lets iCloud know that new keys and values are available to be uploaded." It also reads in any changes that have already arrived, so calling it at launch is a good idea.</p>
<p>This also means the store works without iCloud. If the user is signed out, values are still saved on the device and simply don't sync.</p>
<h3>Listening for changes from other devices</h3>
<p>When another device changes a value, the system posts <code>didChangeExternallyNotification</code>. The <code>userInfo</code> tells you <strong>which keys</strong> changed and <strong>why</strong>:</p>
<pre><code class="language-swift">cancellable = NotificationCenter.default
    .publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification)
    .sink { [weak self] notification in
        let reason = notification.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
        let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] ?? []

        switch reason {
        case NSUbiquitousKeyValueStoreServerChange,
             NSUbiquitousKeyValueStoreInitialSyncChange:
            // Another device wrote something, or this is the first download
            Task { @MainActor in self?.reload(keys: changedKeys) }
        case NSUbiquitousKeyValueStoreQuotaViolationChange:
            // The store is over its limit and won't upload new changes
            break
        case NSUbiquitousKeyValueStoreAccountChange:
            // The user switched iCloud accounts and the store was replaced
            break
        default:
            break
        }
    }
</code></pre>
<p>NewsNinja starts observing this at launch, calls <code>synchronize()</code> once, and then reloads everything from the store. Every property is compared before it's assigned, so a sync from another device only refreshes the views whose data actually changed.</p>
<h3>The limits</h3>
<p>Here's the catch: the store is designed for <strong>small</strong> amounts of data.</p>
<table>
<thead>
<tr>
<th>Limit</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Total storage per app, per user</td>
<td>1 MB</td>
</tr>
<tr>
<td>Maximum number of keys</td>
<td>1,024</td>
</tr>
<tr>
<td>Maximum key length</td>
<td>64 bytes (UTF-8)</td>
</tr>
<tr>
<td>Maximum size of a single value</td>
<td>1 MB (and it counts toward the total)</td>
</tr>
</tbody></table>
<p>The system also throttles how often it syncs, so it's not a good fit for data that changes many times per second. And conflict resolution is simple: <strong>the last write to a key wins</strong>, replacing the whole value.</p>
<p>For a list of feed URLs, none of this matters. A few hundred feeds are a few kilobytes.</p>
<h2>Then the app grew</h2>
<p>Every time NewsNinja learned something new, it was tempting to put that in the store too. And it was always easy: one new key, one <code>didSet</code>.</p>
<p>Over time, the store picked up:</p>
<ul>
<li><strong>Feeds</strong> became full channel objects with a name, homepage, icon, and parser type, encoded as JSON.</li>
<li><strong>Categories</strong>, with their feeds and keywords.</li>
<li><strong>Settings</strong>: grouping algorithm and threshold, article limit, reader type, summary type, translation language, list style, hidden read articles, Reddit comment mode, ticker currency, and blocked words.</li>
<li><strong>Read articles</strong>, so an article you read on your iPhone shows as read on your Mac.</li>
<li><strong>Starred articles</strong>, so the articles you save are on every device.</li>
<li><strong>Email account configuration</strong> (connection settings sync between devices; passwords and authorization credentials remain device-local), so you don't have to set up an inbox twice.</li>
</ul>
<p>The settings are tiny. The feeds and categories stay small. Read articles and starred articles are different: they grow every day you use the app. Those two are where most of the work went.</p>
<h2>Read articles: 25,000 articles in 100 KB</h2>
<p>To remember which articles you've read, NewsNinja needs some identity for every article you open. The obvious choice is the article URL. But URLs are long. I measured 25,000 typical article URLs encoded as a property list, and they take about <strong>1.9 MB</strong>. That's almost twice the size of the entire store, for one feature.</p>
<h3>Store a checksum, not the URL</h3>
<p>NewsNinja doesn't need to know <em>which</em> URL you read. It only needs to answer one question: "Have I seen this article before?" A hash answers that just as well.</p>
<p>From the beginning, NewsNinja stored a <strong>CRC32 checksum</strong> of each article URL instead of the URL itself. CRC32 is a 32-bit value, so every read article costs exactly <strong>4 bytes</strong>, whether its URL is 40 or 400 characters long.</p>
<pre><code class="language-swift">struct CRC32 {
    private static let table: [UInt32] = (0...255).map { i in
        (0..&lt;8).reduce(UInt32(i)) { crc, _ in
            (crc &gt;&gt; 1) ^ (crc &amp; 1 == 1 ? 0xEDB88320 : 0)
        }
    }

    static func checksum(string: String) -&gt; UInt32 {
        ~Data(string.utf8).reduce(~UInt32(0)) { crc, byte in
            (crc &gt;&gt; 8) ^ table[Int((crc &amp; 0xFF) ^ UInt32(byte))]
        }
    }
}

func markArticleAsRead(url: String) {
    readArticles.insert(CRC32.checksum(string: url))
}
</code></pre>
<p>CRC32 isn't a cryptographic hash, but this is not a security feature. Two different URLs can produce the same checksum. With 25,000 read articles in a space of about 4.3 billion values, the chance that a new article falsely shows as read is about <strong>1 in 170,000</strong>. If it happens, the worst outcome is one article that looks read when it isn't. A bigger hash like MD5 or SHA-256 would make collisions even rarer, but at 16 or 32 bytes per article, the same history would take 4 to 8 times the space.</p>
<h3>Store raw bytes, not an array of numbers</h3>
<p>The first version saved the checksums as an array of numbers: <code>[UInt32]</code>. That works, but a property-list array stores every number as a separate object, with a type marker, the value, and an entry in an offset table. In my measurement, 25,000 checksums stored that way took <strong>275 KB</strong>, almost three times the 100 KB of actual data.</p>
<p>So the current format, <code>readArticles_v2</code>, packs the checksums into a single <code>Data</code> blob of 4-byte integers:</p>
<pre><code class="language-swift">// Encode: 25,000 checksums → exactly 100,000 bytes
let snapshot = Array(readArticles)
let data = snapshot.withUnsafeBufferPointer { Data(buffer: $0) }
keyValueStore.set(data, forKey: "readArticles_v2")

// Decode
static func decodeReadArticlesBlob(_ data: Data?) -&gt; Set&lt;UInt32&gt; {
    guard let data, !data.isEmpty,
          data.count.isMultiple(of: MemoryLayout&lt;UInt32&gt;.size) else { return [] }

    let array = data.withUnsafeBytes { buffer in
        Array(buffer.bindMemory(to: UInt32.self))
    }
    return Set(array)
}
</code></pre>
<p>One key, one opaque blob, no per-item overhead.</p>
<h3>Cap it, and don't write on every tap</h3>
<p>Even at 4 bytes per article, read history grows without limit. So NewsNinja sets a budget: when the history goes over <strong>25,000</strong> articles, it trims it back to <strong>20,000</strong>. That's at most <strong>100 KB</strong>, or about 10% of the store, for read state.</p>
<p>Marking articles as read is also the most frequent write in the app. Scrolling through a list with "mark as read" can change dozens of articles in a few seconds. Instead of writing the store every time, NewsNinja <strong>debounces</strong> the save: it waits 500 milliseconds after the last change, encodes the blob off the main thread, then writes it to the store.</p>
<pre><code class="language-swift">private func scheduleReadArticlesPersist() {
    readArticlesPersistTask?.cancel()
    let articles = readArticles

    readArticlesPersistTask = Task { [weak self] in
        try? await Task.sleep(for: .milliseconds(500))
        guard !Task.isCancelled else { return }

        let data = await Task.detached(priority: .utility) {
            let pruned = Self.prunedReadArticles(articles, threshold: 25_000, retainedCount: 20_000)
            return Array(pruned).withUnsafeBufferPointer { Data(buffer: $0) }
        }.value

        guard !Task.isCancelled else { return }
        self?.keyValueStore.set(data, forKey: "readArticles_v2")
    }
}
</code></pre>
<p>The views don't observe the whole set either. Each change publishes only the checksums that changed, so marking one article as read redraws one row, not the entire list.</p>
<h2>Starred articles: when you need the context</h2>
<p>Read articles could be reduced to a checksum because a "yes or no" is all that matters. Starred articles are the opposite.</p>
<p>A starred article has to be <strong>displayable on its own</strong>, on any device, even if you've unsubscribed from the feed or the article has dropped out of it. NewsNinja needs to know the title, link, date, image, and source. And NewsNinja isn't only an RSS reader anymore. A starred Reddit post needs its subreddit, author, score, and comment count. A starred post from X needs its text, author, and media. A starred podcast episode needs its audio URL, duration, and transcript URL.</p>
<p>That's a lot of context, and there's no way to hash it away. So I had to get inventive.</p>
<h3>Version 1: save the whole article</h3>
<p>The first version simply encoded the app's full article model as JSON and stored the string. It worked, and it was the fastest way to ship the feature. But the full model carries everything the app needs while you're reading: the description, the content, the complete channel object, and every field from the original Reddit or X response. Most of that isn't needed to show a starred article in a list, and a few dozen stars could take a meaningful part of the 1 MB store.</p>
<h3>Version 2: a dedicated storage model</h3>
<p>The fix was to stop persisting the app's model and introduce a separate, flat <strong>storage model</strong> with only the fields needed to rebuild a starred article:</p>
<pre><code class="language-swift">struct NNStarredArticleData: Codable {
    let title: String
    let link: URL
    let pubDate: Date?
    let mediaThumbnail: URL?
    let channelFeedURL: URL      // a reference, not the whole channel
    let channelName: String
    let channelImage: String?
    let redditSubreddit: String?
    let redditScore: Int?
    let twitterPost: NNStarredTwitterPostData?
    let audioURL: URL?
    // ...
}
</code></pre>
<p>A few decisions do most of the work here:</p>
<ul>
<li><strong>No article body.</strong> The description and content are never stored. They can be fetched again when you open the article.</li>
<li><strong>References instead of copies.</strong> The channel is stored as its feed URL. When loading, NewsNinja looks the channel up in your feeds and only falls back to the stored name and icon if you've unsubscribed.</li>
<li><strong>Flattened sources.</strong> A Reddit post becomes a handful of optional fields. A post from X is converted into a smaller struct with just the text, author, counts, and media URLs.</li>
</ul>
<p>Categories got the same treatment: they now store a list of feed URLs instead of embedding full copies of every channel.</p>
<h3>Short keys</h3>
<p>JSON repeats every property name for every object. With hundreds of starred articles, the name <code>redditNumComments</code> is written hundreds of times. So the storage model uses short keys:</p>
<pre><code class="language-swift">private enum CodingKeys: String, CodingKey {
    case title = "t"
    case link = "l"
    case pubDate = "d"
    case channelFeedURL = "cf"
    case channelName = "cn"
    case redditScore = "rsc"
    case redditNumComments = "rcc"
    case twitterPost = "x"
    case audioURL = "au"
    // ...
}
</code></pre>
<p>This only changes the encoding. The Swift properties keep their readable names, and the decoder still accepts the old long keys, so data saved by an older version of the app keeps working.</p>
<h3>Version 3: compress it</h3>
<p>The last step was compression. Starred articles are mostly URLs, and URLs from the same sites share long prefixes like <code>https://www.reddit.com/r/</code>. JSON full of repeated strings compresses very well, so <code>starred_articles_v3</code> is stored as zlib-compressed <code>Data</code> instead of a JSON string:</p>
<pre><code class="language-swift">private func persistStarredArticles() {
    let storageData = starredArticles.map { NNStarredArticleData(from: $0) }
    guard let json = try? JSONEncoder().encode(storageData),
          let compressed = try? (json as NSData).compressed(using: .zlib) as Data else { return }

    keyValueStore.set(compressed, forKey: "starred_articles_v3")
}

private func loadStarredArticles() -&gt; [FPRssItem] {
    guard let compressed = keyValueStore.data(forKey: "starred_articles_v3"),
          let json = try? (compressed as NSData).decompressed(using: .zlib) as Data,
          let stored = try? JSONDecoder().decode([NNStarredArticleData].self, from: json) else {
        return []
    }
    return stored.map { $0.toRssItem(channels: rssFeedsChannels) }
}
</code></pre>
<p>Each step stacks on the previous one: fewer fields, shorter keys, then compression.</p>
<h2>Migrating without losing anything</h2>
<p>Each format change uses a <strong>new key</strong>, like <code>readArticles_v2</code> or <code>starred_articles_v3</code>, instead of overwriting the old one. Loading always tries the newest format first and falls back to older ones:</p>
<pre><code class="language-swift">// v3: compressed Data
if let data = keyValueStore.data(forKey: "starred_articles_v3") { ... }
// v2: plain JSON string with the compact model
else if let json = keyValueStore.string(forKey: "starred_articles_v2") { ... }
// v1: the original full model
else if let json = keyValueStore.string(forKey: "starred_articles") { ... }
</code></pre>
<p>This matters because sync is not instant, and not every device updates at the same time. Your iPad might still be running an older version when your iPhone writes a new format. Old keys are removed only after the data has been written in the new format, and before the first migration NewsNinja saves a backup of your feeds and categories as an OPML file in the app's Documents folder, just in case.</p>
<h2>Keeping an eye on the budget</h2>
<p>With everything sharing 1 MB, it helps to know where the bytes are going. NewsNinja estimates the size of every synced key by serializing it as a binary property list:</p>
<pre><code class="language-swift">private func estimatePayloadSize(for object: Any) -&gt; Int? {
    try? PropertyListSerialization.data(fromPropertyList: object, format: .binary, options: 0).count
}
</code></pre>
<p>This powers a sync status screen in Settings. It shows the estimated store size, the largest keys, when this device last wrote something, when it last heard from iCloud, and any issue such as a quota violation or an account change. When something doesn't sync, it's much easier to understand why.</p>
<p>Finally, not everything belongs in iCloud. Reader font, font size, theme, section layout, and feed type colors stay in local <code>UserDefaults</code>. They're often different on a phone and a Mac, and keeping them local saves a few more bytes.</p>
<h2>Was it the right call?</h2>
<p>For NewsNinja, yes. <code>NSUbiquitousKeyValueStore</code> gave me two years of cross-device sync with:</p>
<ul>
<li><strong>No database</strong> to design or migrate.</li>
<li><strong>No server</strong>, and no accounts.</li>
<li><strong>No CloudKit schema</strong> to deploy.</li>
<li>A <strong>synchronous API</strong> that works offline and plugs straight into SwiftUI.</li>
</ul>
<p>It's plain and simple, and simplicity was exactly what a small RSS reader needed.</p>
<p>The trade-offs are real, though. The 1 MB limit has shaped several features. The last-writer-wins rule works per key, so if two devices change the same key before syncing, one device's change replaces the other's. For settings that's fine. For read history stored as one blob, it means an occasional read mark can be lost when two devices are used at the same time.</p>
<p>Newer features are designed around this rule. Email accounts, for example, aren't stored as one array. Each account gets its own key, like <code>privateEmailAccount.v2.&lt;id&gt;</code>, and removing an account writes a separate "tombstone" key instead of rewriting a shared list. Adding an inbox on your iPhone while removing another one on your Mac can't erase each other's changes, because they never touch the same key.</p>
<p>And every new feature has to ask the same question first: "How many bytes will this cost?"</p>
<h2>What's next: SwiftData</h2>
<p>NewsNinja is growing beyond feeds and settings. Future versions will need to store kinds of data that don't fit in a key-value store: larger, structured, and with relationships between items.</p>
<p>For that, I'm looking at moving to <strong>SwiftData</strong> with CloudKit sync. Instead of one value per key, it syncs individual records, so merging changes between devices is more precise, and storage isn't capped at 1 MB. In return, I'll be taking on everything I avoided at the start: a schema, migrations, and CloudKit's modeling rules.</p>
<p>That's the right trade now, but it wasn't two years ago. If you're starting an app and all you need to sync is a list of URLs and a few settings, <code>NSUbiquitousKeyValueStore</code> is still a great place to start. Just keep an eye on your bytes.</p>
<hr />
<p>I'm an engineering manager in Tokyo, building apps and games after hours. If you want to see <code>NSUbiquitousKeyValueStore</code> at work, <a href="https://apps.apple.com/app/id6504148117">NewsNinja is on the App Store</a>. Questions about the approach? Leave a comment, I'm happy to go deeper on any part.</p>
]]></content:encoded></item></channel></rss>