<?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>Forem: Oleksandr Prudnikov</title>
    <description>The latest articles on Forem by Oleksandr Prudnikov (@grommash9).</description>
    <link>https://forem.com/grommash9</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3858743%2Fa724193f-cf3f-42ef-b9c2-26881ab16e61.jpeg</url>
      <title>Forem: Oleksandr Prudnikov</title>
      <link>https://forem.com/grommash9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/grommash9"/>
    <language>en</language>
    <item>
      <title>How I Synced 100K Items from an iOS App Using CSV &amp; GZIP (No Backend)</title>
      <dc:creator>Oleksandr Prudnikov</dc:creator>
      <pubDate>Mon, 06 Apr 2026 21:35:00 +0000</pubDate>
      <link>https://forem.com/grommash9/how-i-synced-100k-items-from-an-ios-app-using-csv-gzip-no-backend-o26</link>
      <guid>https://forem.com/grommash9/how-i-synced-100k-items-from-an-ios-app-using-csv-gzip-no-backend-o26</guid>
      <description>&lt;p&gt;I'm building an iOS inventory tracker. Users add items at markets — often with no mobile signal — and want to see everything in a spreadsheet on their laptop later. The app needed to be offline-first and the sync needed to just work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with Google Sheets API
&lt;/h2&gt;

&lt;p&gt;My first approach: use the Sheets API to update individual cells as users make changes. User taps a price, queue the cell update, sync when online.&lt;/p&gt;

&lt;p&gt;Two problems killed this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queueing was fragile.&lt;/strong&gt; When a user toggles a field quickly — yes, no, no, yes, no — the queue needs to reflect the final state, not deduplicate away intermediate changes. I hit a bug where deduplication logic was collapsing the queue incorrectly, sending stale values. Fixing it properly meant building a mini sync engine for what is essentially a data export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google's permission model.&lt;/strong&gt; The Sheets API has no restricted scope. If your app can read one spreadsheet, it can read every spreadsheet on the user's account. The OAuth consent screen would say "allow this app to read and edit ALL your spreadsheets." That's a terrible first impression.&lt;/p&gt;

&lt;p&gt;To get this scope approved, Google requires video verification — record a demo, explain why you need access, wait for manual review. I had users on a waitlist. Spending weeks on Google verification while they lose interest was not an option.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSV-to-Drive approach
&lt;/h2&gt;

&lt;p&gt;Instead of updating cells one by one, I upload the entire inventory as a single CSV file to Google Drive. The trick: set the MIME type to &lt;code&gt;application/vnd.google-apps.spreadsheet&lt;/code&gt; and Google converts it into a Sheets document automatically.&lt;/p&gt;

&lt;p&gt;The Google Drive &lt;code&gt;drive.file&lt;/code&gt; scope only allows your app to access files it created — it cannot touch anything else on the user's Drive. This scope is &lt;strong&gt;auto-approved with no verification required&lt;/strong&gt;. No video demo, no waiting, no scary consent screen.&lt;/p&gt;

&lt;p&gt;From the user's perspective nothing changed. They get a spreadsheet link, open it, see all their items with prices and calculations. They don't care that it's a CSV behind the scenes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file size problem and GZIP
&lt;/h2&gt;

&lt;p&gt;I stress-tested the app with 100,000 inventory items. The CSV was huge. Each row implicitly carries the weight of column headers, commas, and quotes. Uploading this over mobile every time the user closes the app was not going to work.&lt;/p&gt;

&lt;p&gt;At a previous job I'd seen microservices communicate with &lt;code&gt;Content-Encoding: gzip&lt;/code&gt; headers — compressed traffic between APIs to reduce latency. I knew the concept but hadn't applied it to file uploads.&lt;/p&gt;

&lt;p&gt;Before GZIP, I spent time manually optimising the CSV — truncating column headers, stripping null values, omitting trailing empty fields. Google Drive supports all of this. But once I added GZIP encoding, all of that manual work became pointless. &lt;strong&gt;GZIP gave approximately 20x compression.&lt;/strong&gt; The algorithm handles repetitive text patterns (which CSV is full of) far better than any manual optimisation.&lt;/p&gt;

&lt;p&gt;Swift has built-in GZIP support through the Compression framework, so implementation was minimal — compress the data, add the content encoding header, upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fitting sync into iOS background tasks
&lt;/h2&gt;

&lt;p&gt;iOS is strict about background work. Two main options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BGProcessingTask&lt;/strong&gt; — runs overnight when charging. Timing is unpredictable, might not run if battery is low&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;beginBackgroundTask&lt;/strong&gt; — gives ~30 seconds when the user closes the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went with the 30-second window. When the user closes the app, it builds the CSV, compresses with GZIP, compares against the last uploaded version, and uploads if needed. 100,000 items sync comfortably within the limit.&lt;/p&gt;

&lt;p&gt;The comparison is simple: keep a hash of the last successfully uploaded CSV. On app close, build new CSV, hash it, compare. Same hash = no upload needed. Different = compress and upload.&lt;/p&gt;

&lt;p&gt;Same sync triggers on app launch too, so data stays current from both directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Photos are handled separately
&lt;/h2&gt;

&lt;p&gt;Inventory photos follow a different path. Each photo is compressed using iOS built-in image processing (~10x size reduction) and queued for upload individually. The queue processes photos progressively — on launch, close, and in the background.&lt;/p&gt;

&lt;p&gt;If a user adds 50 photos at once, they upload gradually. Photos go to Google Drive alongside the CSV, under the same &lt;code&gt;drive.file&lt;/code&gt; scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture that emerged
&lt;/h2&gt;

&lt;p&gt;What started as a shortcut turned into something genuinely simpler and more robust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline-first&lt;/strong&gt; — all data lives on the phone. Works with no internet, which matters at outdoor markets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backend&lt;/strong&gt; — zero infrastructure, zero running costs. Google Drive is the only dependency and it's the user's own storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-approved permissions&lt;/strong&gt; — &lt;code&gt;drive.file&lt;/code&gt; scope means no Google verification, no scary consent screens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync is a view, not a source of truth&lt;/strong&gt; — the spreadsheet is an export. If it breaks, phone data is safe. No sync conflicts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional&lt;/strong&gt; — originally required Google sign-in. Now sync is entirely optional&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is this permanent? Probably not at scale. But 100,000 items holds up under stress testing. That's years of headroom. By the time it's outgrown, it'll be clear whether building something more complex is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaway
&lt;/h2&gt;

&lt;p&gt;I usually try to make the technical solution perfect before moving on. This time I had users waiting and couldn't afford to. The shortcut turned out to be better than the "proper" approach would have been — simpler permissions, no backend costs, works offline, syncs fast.&lt;/p&gt;

&lt;p&gt;Sometimes the temporary workaround is the architecture. The trick is knowing when to stop optimising and ship.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>ios</category>
      <category>mobile</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built an offline-first iOS app in 43 days with Swift, SQLite, and Claude Code</title>
      <dc:creator>Oleksandr Prudnikov</dc:creator>
      <pubDate>Fri, 03 Apr 2026 05:10:08 +0000</pubDate>
      <link>https://forem.com/grommash9/i-built-an-offline-first-ios-app-in-43-days-with-swift-sqlite-and-claude-code-4o4j</link>
      <guid>https://forem.com/grommash9/i-built-an-offline-first-ios-app-in-43-days-with-swift-sqlite-and-claude-code-4o4j</guid>
      <description>&lt;p&gt;My wife buys secondhand items at flea markets and car boot sales in the UK and resells them on eBay and Vinted. She was tracking everything in Google Sheets and kept forgetting what she paid for things. When someone sent an offer she had no idea if it was a good deal.&lt;/p&gt;

&lt;p&gt;So I built her an iOS app. That was 43 days ago. It's now on the App Store, completely free, and a few people are actually using it. Here's how I built it and the technical decisions along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the app does
&lt;/h2&gt;

&lt;p&gt;You take a photo at the market, enter the price, and the app tracks the item through its lifecycle: purchased → listed on platforms → sold. It calculates profit after all expenses — entry fees, transport, packaging. Supports 4 currencies with automatic exchange rates. Works completely offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swift + SwiftUI&lt;/strong&gt; — iOS 17+&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite via GRDB&lt;/strong&gt; — local storage with WAL mode, foreign keys, composite indexes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Drive API&lt;/strong&gt; — optional photo sync + CSV export (drive.file scope only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BGTaskScheduler&lt;/strong&gt; — background photo uploads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CoreLocation&lt;/strong&gt; — auto-suggest nearby markets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backend&lt;/strong&gt; — everything runs on device, zero server costs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SwiftUI Views
    ↓
ViewModels (ObservableObject)
    ↓
Services (GoogleAuth, Export, Location, Notification)
    ↓
DatabaseStore (Swift Actor — all CRUD operations)
    ↓
SQLite (GRDB, WAL mode) + Local Images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key decision was making it &lt;strong&gt;offline-first&lt;/strong&gt;. Car boot sales happen in fields with no signal. The app can't depend on network for anything core. Google Drive sync is purely optional — you can use the app forever without ever connecting a Google account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 1–7: JSON files and regret
&lt;/h2&gt;

&lt;p&gt;I started with the simplest possible storage — JSON files in the Documents directory. One file per entity type (items, markets, sellers, expenses). Read the whole file into memory, modify, write it back.&lt;/p&gt;

&lt;p&gt;This worked fine for 20 items. It did not work fine for 200 items. Loading the entire items array into memory on every read was getting slow, and there was no way to do efficient queries like "show me all unsold items sorted by date, paginated."&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 18: The SQLite migration
&lt;/h2&gt;

&lt;p&gt;I migrated everything to GRDB (a Swift SQLite wrapper). This was the most significant single-day change in the project.&lt;/p&gt;

&lt;p&gt;GRDB gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Typed record structs that map to tables&lt;/li&gt;
&lt;li&gt;A migration system (versioned schema changes)&lt;/li&gt;
&lt;li&gt;WAL mode for concurrent reads&lt;/li&gt;
&lt;li&gt;Proper indexes for fast queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My migration system has 4 versions so far:&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;// v1: Initial schema — all tables, indexes, foreign keys&lt;/span&gt;
&lt;span class="c1"&gt;// v2: Composite indexes for paginated list queries&lt;/span&gt;
&lt;span class="c1"&gt;// v3: Crockford Base32 SKU regeneration&lt;/span&gt;
&lt;span class="c1"&gt;// v5: Market "askForEntryFee" boolean column&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The paginated queries went from "load everything into memory and filter" to actual SQL with &lt;code&gt;LIMIT&lt;/code&gt;/&lt;code&gt;OFFSET&lt;/code&gt; on indexed columns. Night and day difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline-first with optional Google sync
&lt;/h2&gt;

&lt;p&gt;The sync model is simple. Items default to "local" status. If you connect Google Drive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Photos queue for upload via &lt;code&gt;SyncQueueService&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BGTaskScheduler&lt;/code&gt; processes the queue even when app is suspended&lt;/li&gt;
&lt;li&gt;CSV export runs on a 5-minute timer — compares &lt;code&gt;MAX(updatedAt)&lt;/code&gt; vs last sync timestamp, only uploads if data changed&lt;/li&gt;
&lt;li&gt;CSV is gzip-compressed before upload&lt;/li&gt;
&lt;li&gt;Google auto-converts the CSV to a Google Sheet (no Sheets API needed)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I deliberately avoided the Sheets API. Using only &lt;code&gt;drive.file&lt;/code&gt; scope means the app can only access files it created itself. Better privacy, simpler OAuth, fewer permissions to explain to users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-currency with exchange rates
&lt;/h2&gt;

&lt;p&gt;My wife buys in GBP at UK markets but sometimes sells in EUR or USD on eBay. The app needs to show profit in your home currency regardless of what currencies you bought and sold in.&lt;/p&gt;

&lt;p&gt;I fetch daily rates from a free API and cache 365 days of history locally in SQLite. For conversions I support three paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct rate&lt;/strong&gt; — GBP → USD&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse rate&lt;/strong&gt; — USD → GBP (1/rate)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross rate via common base&lt;/strong&gt; — EUR → CAD via USD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're offline for days the app uses cached rates and shows a warning that they might be stale. You can refresh manually when you get signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  SKU generation
&lt;/h2&gt;

&lt;p&gt;Every item gets a unique SKU like &lt;code&gt;FH-20D6G0NR&lt;/code&gt;. I use Crockford Base32 encoding — uppercase only, no ambiguous characters (I, L, O, U are excluded). The SKU is time-based with an atomic counter for guaranteed uniqueness.&lt;/p&gt;

&lt;p&gt;Users can copy the SKU and paste it into their eBay/Vinted listing descriptions, then match up sales later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building with Claude Code
&lt;/h2&gt;

&lt;p&gt;I used Claude Code throughout the entire build. It's genuinely how I shipped this in 43 days as a solo developer. The AI handled a lot of the boilerplate — GRDB record types, SwiftUI form layouts, CSV generation, the Google OAuth flow.&lt;/p&gt;

&lt;p&gt;Where it helped most was the migration from JSON to SQLite. I could describe the existing data model and the target schema and Claude Code would generate the migration code, the new record types, and update all the queries.&lt;/p&gt;

&lt;p&gt;Where it didn't help: design decisions. What should be offline vs synced, how to handle currency conversion edge cases, which features actually matter for my wife's workflow. Those are product decisions that need human context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Figma redesign (Day 23)
&lt;/h2&gt;

&lt;p&gt;I'm not a designer but I knew the app looked like a developer built it. So I created a proper design system in Figma first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Font&lt;/strong&gt;: Roboto Flex (variable weight)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design tokens&lt;/strong&gt;: gold &lt;code&gt;#F9DDA5&lt;/code&gt;, badge &lt;code&gt;#BBCBF7&lt;/code&gt;, card bg &lt;code&gt;#F8FAFB&lt;/code&gt;, accent &lt;code&gt;#334B90&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neumorphic shadows&lt;/strong&gt;: two styles — large (radius 30) and small (radius 15)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Light mode only&lt;/strong&gt;: explicit colors everywhere, no system adaptive&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then rebuilt every screen to match the Figma exactly. The 1:1 Figma px → iOS pt mapping made this surprisingly straightforward.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Start with SQLite from day one.&lt;/strong&gt; The JSON-to-SQLite migration was worth it but I lost time building something I knew I'd throw away. If your app will have more than ~50 records, just use a database from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add search earlier.&lt;/strong&gt; Users asked for it immediately. Being able to find an item by title or SKU across all tabs seems obvious in hindsight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't overthink Google integration.&lt;/strong&gt; I spent too long trying to make the sync perfect. The simpler approach — CSV upload with change detection — works well and is much easier to maintain than real-time sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current state
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;~18,100 lines of Swift across 69 source files&lt;/li&gt;
&lt;li&gt;16 selling platforms supported&lt;/li&gt;
&lt;li&gt;4 currencies with daily exchange rates&lt;/li&gt;
&lt;li&gt;4 database migrations&lt;/li&gt;
&lt;li&gt;8-page onboarding walkthrough&lt;/li&gt;
&lt;li&gt;Free on the App Store, no ads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing runs with zero server costs. GitHub Pages for the website, GitHub Actions for CI/CD, Apple Developer account for distribution. That's it.&lt;/p&gt;

&lt;p&gt;If you're curious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App Store&lt;/strong&gt;: &lt;a href="https://apps.apple.com/us/app/flipperhelper/id6759716745" rel="noopener noreferrer"&gt;FlipperHelper&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Website&lt;/strong&gt;: &lt;a href="https://grommash9.github.io/flipper_helper_pages/" rel="noopener noreferrer"&gt;flipperhelper&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blog&lt;/strong&gt;: &lt;a href="https://grommash9.github.io/flipper_helper_pages/blog/" rel="noopener noreferrer"&gt;guides for resellers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions about the architecture, the GRDB setup, or the Claude Code workflow.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>sqlite</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
