Performance

How rem went from 60 seconds to 130 milliseconds — the optimization journey from JXA to EventKit via cgo.

The numbers

Every read command completes in under 200ms. Tested with 224 reminders across 12 lists.

CommandTime
rem lists0.12s
rem list (all 224 reminders)0.13s
rem list --incomplete0.11s
rem show (by prefix)0.11s
rem search "query"0.11s
rem stats0.17s
rem overdue0.12s
rem upcoming0.12s
rem export --format json0.13s

Write operations (via AppleScript) take 0.5-0.8 seconds — the osascript subprocess has a ~0.4s baseline overhead.

The optimization journey

rem went through four performance stages, each achieving an order-of-magnitude improvement.

Stage 1: AppleScript loops (unusable)

The first attempt used AppleScript’s repeat with r in theReminders to iterate through reminders. Even 8 reminders caused a 30+ second timeout. AppleScript per-element property access is catastrophically slow.

Stage 2: JXA bulk access (slow but functional)

Switched to JXA’s columnar access pattern: list.reminders.name() returns all names in a single Apple Event. This was functional but still painfully slow.

Why it was slow: Each Apple Event is a cross-process IPC call to the Reminders app. For 11 properties across 4 lists, that’s 44 IPC calls — each taking 3-4 seconds. The Reminders app serializes all incoming requests.

CommandJXA Time
rem lists8.3s
rem list (all)~60s
rem show~5s
rem search~60s
rem stats~68s

Concurrent osascript processes didn’t help — Reminders.app serializes all Apple Events internally.

Stage 3: Swift EventKit helper (fast, two binaries)

Built a compiled Swift binary using the EventKit framework. EventKit is an in-process framework — direct memory access, no IPC. All reads dropped to under 250ms.

The tradeoff: the build produced two binaries (rem + reminders-helper), complicating installation and making pkg/client harder to distribute as a Go library.

Stage 4: cgo + Objective-C (fast, single binary)

Replaced the Swift helper with an Objective-C file compiled directly into the Go binary via cgo. Same performance as Stage 3, but produces a single binary.

The key insight: go build automatically compiles .m (Objective-C) files when cgo is enabled. No separate compilation step. go install just works.

Before vs after

CommandBefore (JXA)After (EventKit)Speedup
rem lists8.3s0.12s69x
rem list (all 224)~60s0.13s462x
rem list --incomplete~42s0.11s382x
rem show (prefix)~5s0.11s45x
rem search~60s0.11s545x
rem stats~68s0.17s400x
rem export json~60s0.12s500x

Where the time goes

For a typical read operation (~130ms):

PhaseTime
Binary startup~5ms
cgo function call<1ms
EventKit query100-170ms
JSON serialization (ObjC side)<1ms
JSON parsing (Go side)<5ms
Terminal output<5ms

EventKit is the bottleneck — and it’s the fastest possible path. There’s no further optimization to be done for reads on macOS.

Why EventKit is fast

EventKit is an in-process framework. When you call fetchRemindersMatchingPredicate:, it reads directly from the local reminder store (a SQLite database) without any IPC or process boundary crossing.

JXA/AppleScript, by contrast, sends Apple Events to the Reminders.app process. Each event is serialized, sent over Mach IPC, deserialized, processed, and the result sent back the same way. For bulk operations, this overhead compounds dramatically.

Why AppleScript is fine for writes

Write operations are single-item: you create one reminder, update one reminder, delete one reminder. The 0.5s overhead of spawning osascript is acceptable for a CLI command that creates one thing. EventKit writes would save ~0.4s per operation but require more verbose code for the same result.

Known slow path

The --flagged filter falls back to JXA because EventKit’s EKReminder doesn’t expose a flagged property. This takes ~3-4 seconds. All other reads use EventKit and complete in under 200ms.