DEV Community

Oleksandr Prudnikov
Oleksandr Prudnikov

Posted on

Offline-first because my wife hates subscriptions

The constraint

My wife Valentina resells at car boot sales in the UK. She buys items for £1-3 at muddy fields at 7am on Sundays, then lists them on eBay, Vinted, Depop — wherever they sell best. She makes £1,300-1,900 a month doing this.

When I started building FlipperHelper to track her inventory and profits, she gave me two requirements:

  1. It has to work with no signal. Half the car boot sales she visits have terrible coverage. She's logging purchases in real time while walking between stalls, and she can't wait for a spinner.

  2. No monthly subscription. She already pays for eBay fees, Vinted postage, petrol, entry fees. Another £5/month for an app to write down numbers feels wrong to her.

Those two constraints — from one real user — ended up shaping every technical decision in the app.

Decision 1: Local persistence with a Swift actor

No backend means all data lives on the device. The first version used JSON files in the iOS Documents directory, wrapped in a Swift actor for thread-safe access:

actor DataStore {
    static let shared = DataStore()

    private let documentsDirectory: URL

    private init() {
        self.documentsDirectory = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
    }

    func getItems() async throws -> [Item] {
        let url = self.documentsDirectory
            .appendingPathComponent("items.json")
        let data = try Data(contentsOf: url)
        return try JSONDecoder().decode([Item].self, from: data)
    }

    func saveItems(_ items: [Item]) async throws {
        let url = self.documentsDirectory
            .appendingPathComponent("items.json")
        let data = try JSONEncoder().encode(items)
        try data.write(to: url, options: .atomic)
    }
}
Enter fullscreen mode Exit fullscreen mode

The actor keyword gives you serialised access for free — no locks, no dispatch queues, no race conditions. Every read and write goes through the actor's mailbox. For an app where one person is logging purchases at a market stall, this is more than enough.

Why not CoreData or SQLite from the start? Because JSON files are debuggable. I can AirDrop the file off the phone, open it in any text editor, and see exactly what's stored. When you're a solo developer building your first iOS app (I'm a Python developer by day), that debuggability saves hours.

(The app has since migrated to GRDB/SQLite for performance with larger datasets, but the actor pattern stayed — DatabaseStore is still an actor with the same public API.)

Decision 2: Google Drive as dumb storage

Users want their photos backed up and their data exportable. The obvious answer is "build a backend." But a backend means servers, and servers mean monthly costs that I'd need to pass to the user.

Instead, FlipperHelper uses the user's own Google Drive. The OAuth scope is deliberately narrow:

https://www.googleapis.com/auth/drive.file
Enter fullscreen mode Exit fullscreen mode

This is the drive.file scope — the app can only see files it created, not the user's existing Drive files. This matters for trust (users can verify it) and made the Google OAuth review process straightforward.

Images upload to a FlipperHelper_App/images/ folder on Drive. The app creates the folder structure on first sync and caches the folder IDs locally.

Decision 3: CSV-to-Sheets without the Sheets API

Users want spreadsheets. The Google Sheets API requires its own OAuth scope, its own API enablement, and its own quota management. That's a lot of complexity for "export my 200 items to a spreadsheet."

The trick: upload CSV to Drive with the spreadsheet MIME type, and Drive converts it automatically.

func createSpreadsheetFromCSV(
    csvData: Data,
    fileName: String
) async throws -> (fileId: String, url: String) {
    let metadata: [String: Any] = [
        "name": fileName,
        "mimeType": "application/vnd.google-apps.spreadsheet",
    ]

    // Multipart upload: JSON metadata + CSV body
    // Drive sees the target mimeType and converts automatically
    let url = URL(string:
        "https://www.googleapis.com/upload/drive/v3/files"
        + "?uploadType=multipart&fields=id,webViewLink")!

    // ... build multipart body, POST with Bearer token
}
Enter fullscreen mode Exit fullscreen mode

No Sheets API calls, no extra OAuth scope, no formatting code. The user gets a Google Sheet they can open, sort, filter, and share. Done.

Decision 4: Background image sync with BGTaskScheduler

Photos are the heaviest data. A reseller might photograph 30 items in a morning — that's maybe 100MB of images that need to reach Drive eventually, but definitely not over cellular at a car boot sale.

The app queues images locally and syncs them in the background using BGTaskScheduler:

// Registration at app launch
BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.oprudnikov.flipperhelper.sync",
    using: nil
) { task in
    guard let bgTask = task as? BGProcessingTask else { return }
    Task {
        await SyncQueueService.shared.processQueue()
        bgTask.setTaskCompleted(success: true)
    }
}

// Schedule when the app backgrounds
func scheduleSync() {
    let request = BGProcessingTaskRequest(
        identifier: "com.oprudnikov.flipperhelper.sync"
    )
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false
    try? BGTaskScheduler.shared.submit(request)
}
Enter fullscreen mode Exit fullscreen mode

The SyncQueueService keeps a local queue of pending uploads. Each image gets uploaded, and on success, the queue entry is removed. If the upload fails (no network, token expired), it stays in the queue for the next background execution. The user never sees a loading spinner for photo sync — it just happens.

The tradeoffs

What I give up with this architecture:

  • No real-time multi-device sync. One phone, one source of truth. If Valentina and I both wanted to edit inventory simultaneously, this wouldn't work. For a single reseller at a market stall, it's fine.
  • No server-side analytics. I can't see aggregate usage patterns. I rely on App Store analytics and direct user feedback.
  • Drive quotas. Google Drive gives 15GB free. With compressed images, that's thousands of item photos before anyone hits the limit, but it's a ceiling.

What I gain:

  • $0/month infrastructure. The only cost is the $99/year Apple developer fee. No Firebase bill, no AWS bill, no database hosting.
  • Works at 7am in a muddy field. Every feature works offline. Network is a nice-to-have for backup, not a requirement.
  • Trust. Users see exactly one OAuth permission (drive.file), and it does exactly what it says.
  • A paid tier that adds value, not ransoms. When I add eBay API integration (planned), users will pay for a genuine new capability — cross-listing from the app — not for the privilege of keeping their own data.

The punchline

I didn't set out to build an "offline-first app with a serverless architecture." I set out to build something my wife could use at a car boot sale without phone signal and without paying a monthly fee. The constraints from that one real user — the person standing next to me while I was coding — produced architecture that I'd now argue is better than what any design review would have produced.

50 App Store downloads, 25 TestFlight users, built solo in 43 days. It's small. But it works in a muddy field at 7am, and that's the spec.


FlipperHelper — free to download on the App Store. Landing page: grommash9.github.io/flipper_helper_pages

Top comments (0)