How I Made Apple Notes Change Detection 60× Faster
TL;DR: My read-only Apple Notes sync took 49.1 seconds to decide that nothing had changed. Rewriting JavaScript in Swift would not have fixed it. The real problem was an N+1 pattern across process boundaries: thousands of individual Apple Events sent to Notes.app. Vectorizing those reads cut the production no-change path to a median of 825ms — about 60× faster — while preserving the same note, folder, lock-state, and attachment signatures. A raw SQLite query was another 232× faster, but returned 549 internal rows for 467 user-visible notes. Fast is not the same as correct.
The goal: a trustworthy local mirror
I wanted a local, readable archive of Apple Notes with a few strict constraints:
- Apple Notes must remain read-only.
- Note content and attachments must stay on the Mac.
- An unchanged day must not create another full copy.
- A changed day should export only added or modified notes.
- Deleted and replaced notes should retain local history.
- A periodic raw database/container clone should remain available for forensic recovery.
The resulting system has two lanes:
Daily readable lane
Dagu → Bash wrapper → Node.js → osascript/JXA → Apple Events → Notes.app
Weekly forensic lane
Node.js → raw container copy + SQLite backup + integrity check
The daily lane computes a normalized signature for every note from:
stable note ID
title
modification date
lock state
account and folder placement
attachment IDs and names
export path
It does not read every note body merely to detect change. Bodies and attachment payloads are fetched only for notes selected by the diff.
The first working version was correct — but an unchanged run took roughly 49 seconds.
The wrong hypothesis: JavaScript is slow
The exporter uses JavaScript for Automation (JXA):
const Notes = Application("Notes");
It is tempting to see a 49-second JavaScript program and conclude that the language is the problem. Maybe Swift, Rust, or Go would be faster.
But a JXA property access is not a normal in-process object lookup:
const modifiedAt = note.modificationDate();
That line sends an Apple Event from the osascript process to Notes.app. Notes resolves the scripting object, reads the property, serializes a response, and sends it back.
The original inventory walked the object graph one item at a time:
for (const account of Notes.accounts()) {
for (const folder of account.folders()) {
for (const note of folder.notes()) {
const id = note.id();
const name = note.name();
const modifiedAt = note.modificationDate();
const locked = note.passwordProtected();
for (const attachment of note.attachments()) {
const attachmentId = attachment.id();
const attachmentName = attachment.name();
}
}
}
}
With 467 notes and 212 attachments, this produced thousands of cross-process round trips. The JavaScript work was trivial. The IPC pattern was not.
This was the Apple Events version of an ORM N+1 query.
The fix: ask for columns, not objects
Notes supports vectorized object specifiers. Instead of requesting four properties from each of 467 note objects, JXA can request one property from every note:
const ids = Notes.notes.id();
const names = Notes.notes.name();
const dates = Notes.notes.modificationDate();
const locked = Notes.notes.passwordProtected();
const attachmentIds = Notes.notes.attachments.id();
const attachmentNames = Notes.notes.attachments.name();
Each expression sends one Apple Event and returns an array. Attachment properties return nested arrays aligned to the note collection.
The exporter validates the array lengths, verifies that the note ID ordering stayed stable during the batch, then joins the fields in memory:
const records = ids.map((id, index) => ({
id,
name: names[index],
modificationDate: dates[index],
locked: locked[index],
attachmentIds: attachmentIds[index],
attachmentNames: attachmentNames[index],
}));
The CPU still performs O(n) work over the records, but O(n) local array work is cheap. The expensive operation — Apple Events IPC — drops from thousands of calls to a small, mostly fixed set.
One Notes API quirk
I initially tried to batch note placement with:
Notes.notes.container.id();
The expression succeeded but returned 467 null values. Syntactic success was not semantic success.
The reliable alternative was to query membership from the other direction:
for (const folder of folders) {
const noteIds = folder.notes.id();
// Build note ID → folder ID in memory.
}
There were only 11 folders, so this retained the vectorized access pattern. An account-level batch provides a defensive fallback for provider notes that are not exposed through a normal folder.
Direct lookup removes the second N+1
The first incremental exporter wrote only changed notes, but still walked all 467 notes to find them. One changed note paid the lookup cost for the entire library.
Notes supports stable-ID lookup:
const note = Notes.notes.byId(noteId);
In the local microbenchmark, a direct lookup took about 19ms. The new selected-export path carries the normalized metadata for changed IDs and resolves each note directly.
The changed path is now:
vectorized source inventory
→ compare stable signatures
→ APFS copy-on-write mirror candidate
→ archive old versions of affected notes
→ Notes.notes.byId(id) for changed notes only
→ vectorized source inventory again
→ full catalog/source verification
→ atomic symlink promotion
A one-note changed-path canary completed in 2.38 seconds, updated one note, left the other 466 untouched, and passed a full 467-note mirror verification.
Performance without weakening correctness
The optimized path retained the original safety gates and added another one:
- Every collection must return aligned lengths.
- Note IDs must be unique and stable across the batch.
- Every note must reconcile to an account and folder.
- Attachment ID/name collections must align.
- Source signatures before and after export must match.
- The complete merged catalog must match the stable source inventory.
- The readable mirror must pass file/count verification before promotion.
- Promotion uses an atomic symlink switch.
Changed and deleted note payloads remain in private history for one year. The candidate mirror uses APFS copy-on-write, so unchanged file blocks are shared rather than copied again.
The weekly raw clone remains separate. A forensic SQLite/container snapshot should be immutable and internally consistent; trying to patch it note by note would weaken its recovery value.
Benchmark methodology
I built a throwaway, aggregate-only benchmark runner. It never printed note IDs, titles, paths, bodies, or attachment names. It queried the verified raw snapshot rather than the live database for the SQLite tests.
| Setting | Value |
|---|---|
| Hardware | MacBook Pro, Apple M5 Max, 18 CPU cores, 128GB memory |
| OS | macOS 26.6.2 |
| Architecture | arm64 |
| Benchmark runtime | Node.js 24.18.0 |
| Notes API inventory | 467 notes, 212 attachments, 4 accounts, 11 folders |
| Raw SQLite snapshot | 39,211,008 bytes |
| Run state | Warm local runs |
| Repetitions | Fast cases 5–50 runs; legacy per-note case 1 run |
| Scheduling policy | Apple Event microbenchmarks ran at background priority |
The old per-note case ran once because it was already two orders of magnitude slower and generated unnecessary heat. The fast cases include a warm-up and report medians.
Results
| Method | Median | Compared with production | Semantically equivalent? |
|---|---|---|---|
Database file stat | ~0.01ms | ~80,000× faster | No |
| Raw SQLite note query | 3.55ms | ~232× faster | No |
| SHA-256 of 39MB SQLite snapshot | 78.01ms | ~10.6× faster | No |
| JXA vectorized core properties | 286.33ms | — | Partial signature |
| AppleScript vectorized core properties | 340.13ms | — | Partial signature |
| JXA vectorized full signature | 957.88ms | — | Yes |
| Production no-change sync | 824.59ms | Baseline | Yes |
| JXA legacy per-note full signature | 49,136.69ms | ~60× slower | Yes |
The production no-change samples were tightly grouped:
min 793.93ms
median 824.59ms
max 853.95ms
An exact scheduler-path run completed the sync inside one timestamped second. The command that triggered and polled Dagu took 2.36 seconds end to end; most of that difference was orchestration and terminal-status polling, not Notes change detection.
What the language comparison proved
For the same vectorized core fields:
JXA 286.33ms
AppleScript 340.13ms
Difference 53.80ms
JXA was about 16% faster in this run, but the absolute difference was only 54ms.
This falsified the language-rewrite hypothesis. A native Swift Apple Events client might remove some process startup and serialization overhead, but it would still ask Notes.app to perform the same work. Even an optimistic few hundred milliseconds of savings would not justify a second implementation, native build pipeline, signing concerns, and a larger maintenance surface for a once-daily background task.
The 60× improvement came from changing the number of remote calls, not from changing the language that issued them.
Why the 3.55ms SQLite query did not win
The SQLite query was undeniably fast:
SELECT count(*),
count(ZIDENTIFIER),
max(coalesce(ZMODIFICATIONDATE1, ZMODIFICATIONDATE, 0))
FROM ZICCLOUDSYNCINGOBJECT
WHERE Z_ENT = (
SELECT Z_ENT
FROM Z_PRIMARYKEY
WHERE Z_NAME = 'ICNote'
);
But it returned a different domain:
Notes scripting API: 467 notes
Raw SQLite: 549 ICNote rows
Difference: 82 rows
The raw store includes records that the user-facing Notes scripting model does not expose the same way: internal, hidden, provider-specific, deleted, or synchronization-shaped rows. To use it as the primary change detector, I would need to own a versioned adapter for Apple’s private schema, reproduce Notes’ filtering semantics, reconcile folder/provider states, handle WAL consistency, and track attachment storage across OS releases.
The 3.55ms query is not a faster answer to the same question. It is a faster answer to a lower-level question.
It also would not make a changed-note sync take 3.55ms. Exporting a body and attachment payload still requires Notes automation or private blob decoding. At best, raw detection would save most of the current 825ms inventory cost while adding substantially more correctness risk.
Why file stats and hashing also lost
A database stat call is effectively free, but it can only answer whether a file’s inode metadata changed. It cannot identify a note or distinguish a content edit from iCloud housekeeping.
Hashing the 39MB SQLite snapshot took 78ms, but the live Notes store also uses WAL/SHM state and a much larger container for attachments and media. Hashing one database file is not a complete source signature; hashing the whole container would increase disk I/O and still would not identify which note to export.
Both techniques can be useful invalidation hints. Neither is a trustworthy semantic source of truth for this mirror.
What happens at larger scale?
The per-note implementation spent about 105ms per visible note/attachment mix, dominated by IPC. A rough linear extrapolation puts 4,670 notes near eight minutes.
Vectorization keeps the expensive round-trip count nearly fixed, but response payload and local processing still grow with the library. Extrapolating from the measured startup and payload costs suggests roughly 5–10 seconds at 10× the current data, not eight minutes. That is an inference, not a measured claim.
SQLite would likely remain in the millisecond range — while retaining the same semantic mismatch and private-schema ownership cost.
For the actual library, the annual no-change cost illustrates the trade-off:
Legacy per-note scan: ~4.98 hours/year
Current production: ~5.02 minutes/year
Raw SQLite query: ~1.3 seconds/year (query only, not equivalent)
The current system is already below the threshold where further optimization changes the user experience.
The engineering lesson
When an automation script is slow, the host language is often the least interesting layer.
Ask these questions first:
- Is a property access actually an IPC or network call?
- Can the remote system project one property across a whole collection?
- Are you scanning all objects merely to locate a known stable ID?
- Does the faster data source describe the same domain?
- What correctness and maintenance obligations come with the shortcut?
The fastest measured method here was a file metadata read. The fastest structured query was SQLite. The best production design was neither.
Vectorized Apple Events plus a local Node.js state machine delivered the useful optimum: a read-only, local-first, fully verified sync in under one second on unchanged days, with direct changed-note export and a separate raw forensic safety net.
Optimization is not choosing the smallest number in the benchmark table. It is choosing the cheapest system that still answers the right question.