Building Offline-First iOS Apps with GRDB
Why I use SQLiteData + GRDB for Systemic, and the local-first patterns that keep queries fast, testable, and predictable.
Building Offline-First iOS Apps with GRDB
When building Systemic, I needed local persistence that was fast, explicit, and easy to reason about. I chose SQLiteData + GRDB so I could keep full control over queries, migrations, and performance without a backend.
Why SQLiteData + GRDB
GRDB gives a reliable SQLite core, and SQLiteData adds a clean Swift model layer on top:
- Explicit queries with predictable performance
- Type-safe models using
@Table - Simple migrations with
DatabaseMigrator - Local-first by design (sync can be layered in later)
Core Patterns
1) Type-Safe Models
@Table
struct HabitEntry: Identifiable, Equatable {
let id: UUID
var date: Date = Date()
var isCompleted: Bool = false
var notes: String?
var completedAt: Date?
var isAutoCompleted: Bool = false
var autoTrackValue: Double?
var habitID: Habit.ID
}2) Query Helpers
I keep reusable queries in a dedicated file so views stay clean:
extension HabitEntry {
nonisolated static func lastDays(_ count: Int) -> Where<HabitEntry> {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
guard let startDate = calendar.date(byAdding: .day, value: -(count - 1), to: today) else {
return Self.where { _ in false }
}
let start = calendar.startOfDay(for: startDate)
return Self.where { $0.date >= start && $0.date <= today }
}
}
@FetchAll(HabitEntry.lastDays(30)) private var habitEntries: [HabitEntry]3) Database Access via Dependencies
Reads and writes go through a single injected database:
@Dependency(\.defaultDatabase) private var database
func loadHabits() async throws -> [Habit] {
try await database.read { db in
try Habit.activeOrdered.fetchAll(db)
}
}4) Explicit Migrations
Migrations are versioned and fully explicit (no magic):
var migrator = DatabaseMigrator()
migrator.registerMigration("v1_create_tables") { db in
try #sql("""
CREATE TABLE IF NOT EXISTS "habits" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"isActive" INTEGER NOT NULL DEFAULT 1
) STRICT
""").execute(db)
}Analytics Without Heavy Queries
Instead of running expensive queries on every view render, I compute analytics on pre-fetched entries:
func completionRate(
days: Int,
entries: [HabitEntry],
calendar: Calendar = .current
) -> Double {
guard days > 0 else { return 0 }
let today = calendar.startOfDay(for: Date())
guard let startDate = calendar.date(byAdding: .day, value: -(days - 1), to: today) else {
return 0
}
let completedDays = Set(
entries
.filter { $0.habitID == id && $0.isCompleted && calendar.startOfDay(for: $0.date) >= startDate }
.map { calendar.startOfDay(for: $0.date) }
)
return Double(completedDays.count) / Double(days)
}Tradeoffs
- No built-in sync: you have to add CloudKit/iCloud yourself later.
- You own migrations: explicit and safe, but you must keep them disciplined.
When This Stack Works Best
- Apps that must work offline
- Local analytics and complex querying
- Teams that want explicit SQL and predictable performance
Conclusion
SQLiteData + GRDB keeps Systemic fast, testable, and entirely local-first while leaving room for optional sync later. For apps where data ownership and offline reliability matter, it’s a solid, low-friction foundation.
Jack O'Shea
DevOps Engineer and Software Developer building reliable systems with clean code.