<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Francesco Pretelli</title>
    <description>The latest articles on DEV Community by Francesco Pretelli (@timefrancesco).</description>
    <link>https://dev.to/timefrancesco</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4238952%2Fada92c17-7209-467e-b175-e9df51a6ada4.png</url>
      <title>DEV Community: Francesco Pretelli</title>
      <link>https://dev.to/timefrancesco</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/timefrancesco"/>
    <language>en</language>
    <item>
      <title>I built an RSS reader that became much more than an RSS reader</title>
      <dc:creator>Francesco Pretelli</dc:creator>
      <pubDate>Sat, 14 Nov 2026 01:00:00 +0000</pubDate>
      <link>https://dev.to/timefrancesco/i-built-an-rss-reader-that-became-much-more-than-an-rss-reader-9k8</link>
      <guid>https://dev.to/timefrancesco/i-built-an-rss-reader-that-became-much-more-than-an-rss-reader-9k8</guid>
      <description>&lt;p&gt;In early June 2024, I was on the train to work, checking the news like I did every morning. And like every morning, I saw the same story again and again. One thing happened, and every outlet I followed wrote about it. Ten headlines, one piece of news. I’d read a title, realize I already knew it, scroll, and repeat.&lt;/p&gt;

&lt;p&gt;I didn’t want an app that picked the news for me or replaced it with a daily digest. I still wanted to read the original articles and decide for myself. I just wanted to see each story &lt;strong&gt;once&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;None of the readers I tried did that, so I built one. That was NewsNinja.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: an RSS reader that grouped headlines
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx0np5m1hjs8ne79ablw2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx0np5m1hjs8ne79ablw2.webp" alt="NewsNinja 1.0 in June 2024 on iPad and iPhone: lists of headlines with grouped stories marked by a count, articles open in reader mode with Open and Summarize buttons, and related articles from other outlets in a bar at the bottom." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first commit is dated June 6, 2024. Grouping and AI summaries were working the next day, and version 1.0 was ready about ten days later, after roughly 24 hours of actual work. It reached the App Store on June 21.&lt;/p&gt;

&lt;p&gt;It shipped on iPhone, iPad, and &lt;strong&gt;Vision Pro&lt;/strong&gt;. visionOS was a brand-new platform at the time, and I wanted to try it, so it came before the Mac. The native Mac app only arrived seven months later, in January 2025. I wrote about the first version at the time in &lt;a href="https://timefrancesco.medium.com/newsninja-a-smart-rss-reader-to-optmize-reading-time-6af1fa9bf8d7" rel="noopener noreferrer"&gt;NewsNinja: a smart RSS reader to optimize reading time&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It did three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grouped articles about the same story&lt;/strong&gt;, so ten headlines about the same news became one. It launched the week after WWDC, which turned out to be the perfect test.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Opened articles in reader mode&lt;/strong&gt; by default, without the clutter of the original page.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Summarized long articles&lt;/strong&gt; with AI. This was the only paid feature, because it was the only one that cost me money to run.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No account, no server of my own: your feed list was a single array of URLs synced through iCloud. I wrote about how that grew in &lt;a href="https://ainews.ninja/blog/icloud-key-value-store-sync/" rel="noopener noreferrer"&gt;How NewsNinja syncs all your data in less than 1 MB of iCloud&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grouping without AI
&lt;/h3&gt;

&lt;p&gt;Grouping was what set NewsNinja apart from other RSS readers, and it took the longest to get right.&lt;/p&gt;

&lt;p&gt;My first plan was to let AI decide which articles belonged together, and I tried two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One big prompt.&lt;/strong&gt; I sent all the headlines to OpenAI and asked it to group them. It was very, very slow. It often returned only some of the headlines, and the groups changed from one run to the next. I tried different prompts and asked for JSON, but the output still wasn’t reliable enough to build on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings.&lt;/strong&gt; Next I turned each headline into an embedding and compared them. That was faster, but it still took several API calls, and the JSON I got back still wasn’t dependable.&lt;/p&gt;

&lt;p&gt;Maybe I didn’t have enough experience with AI yet, or maybe the models of 2024 just weren’t there. Either way, I couldn’t ship something slow and unpredictable, so I looked for another way.&lt;/p&gt;

&lt;p&gt;I went back to basics. NewsNinja compared headlines with &lt;strong&gt;string similarity algorithms&lt;/strong&gt;. You could even pick one of three algorithms and set your own threshold, because sports headlines and tech headlines don’t look alike. It was fast, it ran on your phone, and it cost nothing. It wasn’t perfect, but it was good enough to make the idea work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ship small, ship often
&lt;/h3&gt;

&lt;p&gt;From the start, I wanted to get something real in front of people quickly and let their feedback decide what came next, instead of spending months on a big first release.&lt;/p&gt;

&lt;p&gt;So the updates came small and fast. In the four months after launch, NewsNinja had &lt;strong&gt;nine releases&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Days after launch:&lt;/strong&gt; Italian, Japanese, Korean, and Chinese, with summaries in your language.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;July:&lt;/strong&gt; feed discovery, a blocklist for words you don’t want to see, sharing, and OPML import to bring your feeds from another reader.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;August:&lt;/strong&gt; read and unread articles, better RSS parsing, and font and reader themes.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each release added only a few things. That kept the app moving, and it kept me from burning out on a side project: there was always something new in the hands of real users, and real feedback telling me whether it was worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Can it also do…?”
&lt;/h2&gt;

&lt;p&gt;The first version ended with a list of plans: a blocklist for words you never want to see, card layouts, categories, a Mac app, translations, themes, and support for YouTube and Reddit. Over the next two years, almost all of it shipped, and much more.&lt;/p&gt;

&lt;p&gt;Most of it came from users. People liked that NewsNinja was &lt;strong&gt;simple, private, and fast&lt;/strong&gt;, and they kept asking for more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Import my feeds&lt;/strong&gt; from the reader I already use.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reddit and YouTube&lt;/strong&gt;: Reddit came with version 2.0 in summer 2025, and YouTube a few weeks later.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;X&lt;/strong&gt;, and later Mastodon, Bluesky, and Hacker News, all without signing in.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Podcasts&lt;/strong&gt;, with transcripts and summaries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Translation&lt;/strong&gt;, read-aloud, fact-checking, and email newsletters.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8yrshhek2asusg23jqqm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8yrshhek2asusg23jqqm.webp" alt="NewsNinja 3.3 on four iPhones: a YouTube video with an AI summary and key moments, a podcast episode with its transcript, a fact check marking a viral claim as false, and an email inbox with newsletters." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every one of those requests made sense, and each one made people stay longer. Reddit and YouTube support, a better interface, and a steady stream of new features did more for retention than anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it found users, without marketing
&lt;/h2&gt;

&lt;p&gt;I never really did marketing. I posted on Product Hunt, on Reddit, and on my own X and LinkedIn profiles, and that’s about it. &lt;strong&gt;Most users came from Reddit.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Timing helped. In 2024, AI hadn’t yet flooded the App Store with apps built in a weekend, and people on Reddit were genuinely interested in a new RSS reader.&lt;/p&gt;

&lt;p&gt;A few things made a clear difference after that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Localization.&lt;/strong&gt; NewsNinja supports multiple languages, with a dedicated App Store description and screenshots for each one. That opened up countries I would never have reached otherwise.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Mac app.&lt;/strong&gt; Shortly after it launched in January 2025, NewsNinja went &lt;strong&gt;#1 in the News category of the Japanese Mac App Store&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Black Friday.&lt;/strong&gt; A discount brought a noticeable wave of new users.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Letting people choose how to pay.&lt;/strong&gt; NewsNinja+ is available as a subscription or as a one-time purchase. &lt;strong&gt;Most people pick the one-time purchase.&lt;/strong&gt; Offering it clearly helped, especially with the kind of people who use RSS readers.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is feature creep bad?
&lt;/h2&gt;

&lt;p&gt;At some point, NewsNinja stopped being an RSS reader in any meaningful sense. Today it follows websites, Reddit, YouTube, podcasts, X, Mastodon, Bluesky, Hacker News, and email. It has a dashboard called &lt;strong&gt;Today&lt;/strong&gt;, a full list called &lt;strong&gt;Home&lt;/strong&gt;, &lt;strong&gt;Stories&lt;/strong&gt; built from your own feeds, and &lt;strong&gt;Top News&lt;/strong&gt; from outlets around the world.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7cm685yme5ek8q7x81mk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7cm685yme5ek8q7x81mk.webp" alt="The NewsNinja Today dashboard on three iPhones, in the Magazine, Compact, and Terminal themes, with weather, calendar events, reminders, and an AI Briefing." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That’s hard to describe in one sentence. It doesn’t fit an App Store subtitle, and it’s a big reason why Top News now has &lt;a href="https://ainews.ninja/blog/top-news-on-the-web/" rel="noopener noreferrer"&gt;its own page on the web&lt;/a&gt;: showing it is easier than explaining it.&lt;/p&gt;

&lt;p&gt;But I don’t think it’s a problem anymore. Every one of those features is something people use, and together they add up to something clearer than “an RSS reader”: &lt;strong&gt;one place that collects everything you follow, so you can catch up with your day and move on.&lt;/strong&gt; The feature list grew, but the idea under it got simpler.&lt;/p&gt;

&lt;p&gt;What never changed: no NewsNinja account, your data synced through your own iCloud, and an app built to save your attention instead of selling it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full circle
&lt;/h2&gt;

&lt;p&gt;Two and a half years after that train ride, NewsNinja 3.3 shipped &lt;a href="https://ainews.ninja/blog/introducing-newsninja-3-3/" rel="noopener noreferrer"&gt;&lt;strong&gt;Stories&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26kzq2z6qjr60xlvr0st.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26kzq2z6qjr60xlvr0st.webp" alt="NewsNinja Stories on iPad and iPhone in 2026: articles from several sources grouped into one story, with an AI title, a short overview, and every source that covered it." width="720" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stories groups the articles from your feeds into events, writes a title and a one-line summary for each one, and lists every source that covered it. It works across languages, and only news reported by at least two of your sources becomes a story. And funnily enough, it’s built on embeddings and several API calls, the same approach I gave up on in 2024. This time it works, and it’s reliable.&lt;/p&gt;

&lt;p&gt;It’s exactly what I wanted on that train. It just took two years, and a lot of other features, for the technology to catch up with the idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d do differently
&lt;/h2&gt;

&lt;p&gt;If I started NewsNinja today, I’d use AI much earlier, and for more than code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design.&lt;/strong&gt; I’d get to a better design from the start, instead of fixing it version by version.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Architecture and planning.&lt;/strong&gt; Things like sync and data storage were decided for a simple RSS reader and had to be stretched for everything that came after. Planning with AI would have helped me see further ahead.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What’s next
&lt;/h2&gt;

&lt;p&gt;The backlog is long. One sneak peek: a &lt;strong&gt;time machine&lt;/strong&gt; of everything you’ve read or opened in NewsNinja, so you can go back to any day and find that article you remember but can’t place, with stats, streaks, and more.&lt;/p&gt;

&lt;p&gt;If you’ve built something that grew the same way, I’d like to hear how you decided what to keep. And if you were one of the people who found NewsNinja on Reddit two years ago, thank you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apps.apple.com/app/id6504148117" rel="noopener noreferrer"&gt;Download NewsNinja on the App Store&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ainews.ninja/blog/rss-reader-became-more/" rel="noopener noreferrer"&gt;NewsNinja Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>startup</category>
      <category>ios</category>
      <category>ai</category>
    </item>
    <item>
      <title>How NewsNinja syncs all your data in less than 1 MB of iCloud</title>
      <dc:creator>Francesco Pretelli</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:24:37 +0000</pubDate>
      <link>https://dev.to/timefrancesco/how-newsninja-syncs-all-your-data-in-less-than-1-mb-of-icloud-pck</link>
      <guid>https://dev.to/timefrancesco/how-newsninja-syncs-all-your-data-in-less-than-1-mb-of-icloud-pck</guid>
      <description>&lt;p&gt;&lt;em&gt;&lt;a href="https://ainews.ninja" rel="noopener noreferrer"&gt;NewsNinja&lt;/a&gt; is an RSS reader for iPhone, iPad, Mac, and Vision Pro that I build after hours. This post was originally published on the &lt;a href="https://ainews.ninja/blog/icloud-key-value-store-sync/" rel="noopener noreferrer"&gt;NewsNinja blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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: &lt;strong&gt;&lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt;&lt;/strong&gt;, the iCloud key-value store.&lt;/p&gt;

&lt;p&gt;The whole thing fits in &lt;strong&gt;less than 1 MB&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with a list of URLs
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt; was exactly the right size for the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How NSUbiquitousKeyValueStore works
&lt;/h2&gt;

&lt;p&gt;If you know &lt;code&gt;UserDefaults&lt;/code&gt;, you already know most of the API. It's a dictionary of keys and property-list values (strings, numbers, booleans, dates, &lt;code&gt;Data&lt;/code&gt;, arrays, and dictionaries) that the system syncs through the user's iCloud account.&lt;/p&gt;

&lt;p&gt;To use it, enable the &lt;strong&gt;iCloud&lt;/strong&gt; capability in Xcode and tick &lt;strong&gt;Key-value storage&lt;/strong&gt;. This adds the &lt;code&gt;com.apple.developer.ubiquity-kvstore-identifier&lt;/code&gt; entitlement. No CloudKit container or schema needed.&lt;/p&gt;

&lt;p&gt;This is close to the first &lt;code&gt;KeyValueStore&lt;/code&gt; that shipped in NewsNinja, back in June 2024:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Foundation&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Combine&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KeyValueStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KeyValueStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;keyValueStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NSUbiquitousKeyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;rssFeedsKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rssFeeds"&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cancellable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AnyCancellable&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;

    &lt;span class="nd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rssFeeds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;didSet&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rssFeeds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rssFeedsKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synchronize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;rssFeeds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rssFeedsKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;addRssFeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;feedUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rssFeeds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feedUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;rssFeeds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feedUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;@Published&lt;/code&gt; property.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writes are local first
&lt;/h3&gt;

&lt;p&gt;When you call &lt;code&gt;set(_:forKey:)&lt;/code&gt;, 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. &lt;code&gt;synchronize()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listening for changes from other devices
&lt;/h3&gt;

&lt;p&gt;When another device changes a value, the system posts &lt;code&gt;didChangeExternallyNotification&lt;/code&gt;. The &lt;code&gt;userInfo&lt;/code&gt; tells you &lt;strong&gt;which keys&lt;/strong&gt; changed and &lt;strong&gt;why&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;cancellable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NotificationCenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publisher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NSUbiquitousKeyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;didChangeExternallyNotification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kd"&gt;weak&lt;/span&gt; &lt;span class="kc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;NSUbiquitousKeyValueStoreChangeReasonKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;changedKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;NSUbiquitousKeyValueStoreChangedKeysKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;NSUbiquitousKeyValueStoreServerChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="nc"&gt;NSUbiquitousKeyValueStoreInitialSyncChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;// Another device wrote something, or this is the first download&lt;/span&gt;
            &lt;span class="nc"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nd"&gt;@MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;changedKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;NSUbiquitousKeyValueStoreQuotaViolationChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;// The store is over its limit and won't upload new changes&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;NSUbiquitousKeyValueStoreAccountChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;// The user switched iCloud accounts and the store was replaced&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NewsNinja starts observing this at launch, calls &lt;code&gt;synchronize()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The limits
&lt;/h3&gt;

&lt;p&gt;Here's the catch: the store is designed for &lt;strong&gt;small&lt;/strong&gt; amounts of data.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total storage per app, per user&lt;/td&gt;
&lt;td&gt;1 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum number of keys&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum key length&lt;/td&gt;
&lt;td&gt;64 bytes (UTF-8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum size of a single value&lt;/td&gt;
&lt;td&gt;1 MB (and it counts toward the total)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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: &lt;strong&gt;the last write to a key wins&lt;/strong&gt;, replacing the whole value.&lt;/p&gt;

&lt;p&gt;For a list of feed URLs, none of this matters. A few hundred feeds are a few kilobytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the app grew
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;didSet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Over time, the store picked up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feeds&lt;/strong&gt; became full channel objects with a name, homepage, icon, and parser type, encoded as JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categories&lt;/strong&gt;, with their feeds and keywords.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read articles&lt;/strong&gt;, so an article you read on your iPhone shows as read on your Mac.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starred articles&lt;/strong&gt;, so the articles you save are on every device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email account configuration&lt;/strong&gt; (connection settings sync between devices; passwords and authorization credentials remain device-local), so you don't have to set up an inbox twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read articles: 25,000 articles in 100 KB
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;1.9 MB&lt;/strong&gt;. That's almost twice the size of the entire store, for one feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Store a checksum, not the URL
&lt;/h3&gt;

&lt;p&gt;NewsNinja doesn't need to know &lt;em&gt;which&lt;/em&gt; URL you read. It only needs to answer one question: "Have I seen this article before?" A hash answers that just as well.&lt;/p&gt;

&lt;p&gt;From the beginning, NewsNinja stored a &lt;strong&gt;CRC32 checksum&lt;/strong&gt; of each article URL instead of the URL itself. CRC32 is a 32-bit value, so every read article costs exactly &lt;strong&gt;4 bytes&lt;/strong&gt;, whether its URL is 40 or 400 characters long.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;CRC32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;crc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mh"&gt;0xEDB88320&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UInt32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;crc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;byte&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;markArticleAsRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;readArticles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CRC32&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;strong&gt;1 in 170,000&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Store raw bytes, not an array of numbers
&lt;/h3&gt;

&lt;p&gt;The first version saved the checksums as an array of numbers: &lt;code&gt;[UInt32]&lt;/code&gt;. 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 &lt;strong&gt;275 KB&lt;/strong&gt;, almost three times the 100 KB of actual data.&lt;/p&gt;

&lt;p&gt;So the current format, &lt;code&gt;readArticles_v2&lt;/code&gt;, packs the checksums into a single &lt;code&gt;Data&lt;/code&gt; blob of 4-byte integers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Encode: 25,000 checksums → exactly 100,000 bytes&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readArticles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withUnsafeBufferPointer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"readArticles_v2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Decode&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;decodeReadArticlesBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isMultiple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MemoryLayout&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withUnsafeBytes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bindMemory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One key, one opaque blob, no per-item overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cap it, and don't write on every tap
&lt;/h3&gt;

&lt;p&gt;Even at 4 bytes per article, read history grows without limit. So NewsNinja sets a budget: when the history goes over &lt;strong&gt;25,000&lt;/strong&gt; articles, it trims it back to &lt;strong&gt;20,000&lt;/strong&gt;. That's at most &lt;strong&gt;100 KB&lt;/strong&gt;, or about 10% of the store, for read state.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;debounces&lt;/strong&gt; the save: it waits 500 milliseconds after the last change, encodes the blob off the main thread, then writes it to the store.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scheduleReadArticlesPersist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;readArticlesPersistTask&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readArticles&lt;/span&gt;

    &lt;span class="n"&gt;readArticlesPersistTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kd"&gt;weak&lt;/span&gt; &lt;span class="kc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;milliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isCancelled&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detached&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utility&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pruned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prunedReadArticles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retainedCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pruned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withUnsafeBufferPointer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;

        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isCancelled&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"readArticles_v2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starred articles: when you need the context
&lt;/h2&gt;

&lt;p&gt;Read articles could be reduced to a checksum because a "yes or no" is all that matters. Starred articles are the opposite.&lt;/p&gt;

&lt;p&gt;A starred article has to be &lt;strong&gt;displayable on its own&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;That's a lot of context, and there's no way to hash it away. So I had to get inventive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version 1: save the whole article
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version 2: a dedicated storage model
&lt;/h3&gt;

&lt;p&gt;The fix was to stop persisting the app's model and introduce a separate, flat &lt;strong&gt;storage model&lt;/strong&gt; with only the fields needed to rebuild a starred article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;NNStarredArticleData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Codable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;mediaThumbnail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;channelFeedURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;      &lt;span class="c1"&gt;// a reference, not the whole channel&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;channelImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;redditSubreddit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;redditScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;twitterPost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NNStarredTwitterPostData&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;audioURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions do most of the work here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No article body.&lt;/strong&gt; The description and content are never stored. They can be fetched again when you open the article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;References instead of copies.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flattened sources.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Categories got the same treatment: they now store a list of feed URLs instead of embedding full copies of every channel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Short keys
&lt;/h3&gt;

&lt;p&gt;JSON repeats every property name for every object. With hundreds of starred articles, the name &lt;code&gt;redditNumComments&lt;/code&gt; is written hundreds of times. So the storage model uses short keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;CodingKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CodingKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"t"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"l"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"d"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;channelFeedURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cf"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;channelName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cn"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;redditScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rsc"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;redditNumComments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rcc"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;twitterPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"x"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;audioURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"au"&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version 3: compress it
&lt;/h3&gt;

&lt;p&gt;The last step was compression. Starred articles are mostly URLs, and URLs from the same sites share long prefixes like &lt;code&gt;https://www.reddit.com/r/&lt;/code&gt;. JSON full of repeated strings compresses very well, so &lt;code&gt;starred_articles_v3&lt;/code&gt; is stored as zlib-compressed &lt;code&gt;Data&lt;/code&gt; instead of a JSON string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;persistStarredArticles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;storageData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;starredArticles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;NNStarredArticleData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;JSONEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;storageData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;NSData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"starred_articles_v3"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;loadStarredArticles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;FPRssItem&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"starred_articles_v3"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;NSData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;NNStarredArticleData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toRssItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rssFeedsChannels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step stacks on the previous one: fewer fields, shorter keys, then compression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating without losing anything
&lt;/h2&gt;

&lt;p&gt;Each format change uses a &lt;strong&gt;new key&lt;/strong&gt;, like &lt;code&gt;readArticles_v2&lt;/code&gt; or &lt;code&gt;starred_articles_v3&lt;/code&gt;, instead of overwriting the old one. Loading always tries the newest format first and falls back to older ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// v3: compressed Data&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"starred_articles_v3"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// v2: plain JSON string with the compact model&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"starred_articles_v2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// v1: the original full model&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyValueStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"starred_articles"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping an eye on the budget
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;estimatePayloadSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;PropertyListSerialization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fromPropertyList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Finally, not everything belongs in iCloud. Reader font, font size, theme, section layout, and feed type colors stay in local &lt;code&gt;UserDefaults&lt;/code&gt;. They're often different on a phone and a Mac, and keeping them local saves a few more bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it the right call?
&lt;/h2&gt;

&lt;p&gt;For NewsNinja, yes. &lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt; gave me two years of cross-device sync with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No database&lt;/strong&gt; to design or migrate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No server&lt;/strong&gt;, and no accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No CloudKit schema&lt;/strong&gt; to deploy.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;synchronous API&lt;/strong&gt; that works offline and plugs straight into SwiftUI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's plain and simple, and simplicity was exactly what a small RSS reader needed.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Newer features are designed around this rule. Email accounts, for example, aren't stored as one array. Each account gets its own key, like &lt;code&gt;privateEmailAccount.v2.&amp;lt;id&amp;gt;&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;And every new feature has to ask the same question first: "How many bytes will this cost?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next: SwiftData
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For that, I'm looking at moving to &lt;strong&gt;SwiftData&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt; is still a great place to start. Just keep an eye on your bytes.&lt;/p&gt;




&lt;p&gt;I'm an engineering manager in Tokyo, building apps and games after hours. If you want to see &lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt; at work, &lt;a href="https://apps.apple.com/app/id6504148117" rel="noopener noreferrer"&gt;NewsNinja is on the App Store&lt;/a&gt;. Questions about the approach? Leave a comment, I'm happy to go deeper on any part.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>mobile</category>
      <category>ipad</category>
    </item>
  </channel>
</rss>
