The capture screen quietly did work proportional to your whole backlog on every character you typed. This change makes typing cost the same whether you have 5 notes or 500 — while the on-disk backup stays exactly as fresh.
todo is an offline-first notes app. Its landing screen — the capture screen — is a big always-focused text box. The whole point is speed of capture: your idea is persisted on every keystroke, so nothing is ever lost, even to a crash.
Two durability layers matter here:
NoteRepository). Each keystroke calls upsert(note), one small write. The repository also offers two ways to read:
listNotes() — a one-shot query: run it, get the current notes once.watchNotes() — a live stream: it re-runs its SELECT * and re-emits every time the table changes, so the UI can stay in sync automatically.~/todo/BACKLOG.md), managed by LocalBackup. It's a human-readable third copy you (or an LLM) can read directly. LocalBackup debounces so a burst of typing produces a single file write.The subtle part is how those two were wired together. To keep the backup current, the capture screen subscribed to watchNotes() and handed each emission to the backup:
_notesSub = repository.watchNotes().listen(_localBackup.scheduleExport);
That one line is the origin of the whole problem — read on.
Here's the trap. watchNotes() re-emits on any write to the notes table. But every keystroke is a write (the autosave upsert). So the live stream fired on every character — and each firing did the full pipeline:
SELECT * FROM notes (the whole table),Note (two DateTime.parse calls each), andAll of that is O(notes) work, on the UI thread, per character. With 5 notes it's invisible. With 500, every keystroke re-parses 500 rows and re-serializes a big string. That is the part that would get worse as your backlog grew.
Toy example — type "hello" (5 keys) with 300 notes:
The backup doesn't need the notes on every keystroke — it only needs them once, when it's actually about to write the file. So we split "something changed" from "give me the data":
upsertupsertlistNotes()The tick is a payload-free Stream<void>: it just says "a write happened." The expensive query-parse-serialize now runs at most once per idle window, not per character.
upsert, delete, merge, and importNotes which calls upsert) funnels through the repository and fires the tick. Nothing that changes data escapes it.
The save indicator. Typing also fired setState() to update the tiny "Saved locally at 12:04" line — which rebuilt the entire screen (app bar, priority/status dropdowns, the editor) on every keystroke. Now that timestamp lives in a ValueNotifier wrapped by a ValueListenableBuilder, so a keystroke repaints only that one line of text.
List keys. Notes-list rows now carry key: ValueKey(note.id), so when the list reorders (a note bubbles to the top after an edit) Flutter matches rows by identity instead of by position.
Set a backlog size, then type in the box. Each keystroke, watch the Before and After tallies of main-thread work diverge. Stop typing for two seconds and the After side does its single export.
The bars show cumulative main-thread units of O(notes) work. Pause typing to trigger the After-side export.
Grouped by concern, not by file. Four moves.
A broadcast Stream<void> that fires once after each write. O(1): no query, no parsing.
+ final StreamController<void> _changes = StreamController<void>.broadcast();
+ Stream<void> get changes => _changes.stream;
+ void _notifyChanged() { if (!_changes.isClosed) _changes.add(null); }
Future<void> upsert(Note note) async {
await _crdt.execute( /* INSERT ... ON CONFLICT ... */ );
+ _notifyChanged();
}
// delete() and merge() gain the same one-line tick;
// importNotes() is covered because it calls upsert().
close() now also closes the controller so a late write during teardown can't add to a dead stream.
LocalBackup pulls notes on write, not on keystrokeThe API changes from "here are the notes, schedule them" to "schedule a write; I'll fetch the notes myself when the timer fires."
LocalBackup({
+ required this.fetch, // Future<List<Note>> Function()
required this.reader, required this.writer,
this.debounce = const Duration(seconds: 2),
});
- void scheduleExport(List<Note> notes) {
- final markdown = NotesMarkdown.export(notes); // built EVERY call
- ... writer(markdown) ...
- }
+ void schedule() { // just (re)arm the timer — O(1)
+ _timer?.cancel();
+ _timer = Timer(debounce, () => unawaited(_export()));
+ }
+ Future<void> _export() async { // runs at most once per idle window
+ final notes = await fetch(); // one query, only now
+ await writer(NotesMarkdown.export(notes));
+ }
- _notesSub = repository.watchNotes().listen(_localBackup.scheduleExport);
+ _changesSub = repository.changes.listen((_) => _localBackup.schedule());
+ _localBackup.schedule(); // one refresh on launch (the tick only fires on writes)
The production backup's fetch is wired to repository.listNotes. The launch schedule() replaces a side-effect the old watchNotes() gave for free: it emitted once on subscribe, keeping BACKLOG.md fresh even in a session with no edits.
- DateTime? _lastSavedAt; // a plain field...
- if (mounted) setState(() => _lastSavedAt = now); // ...rebuilt the whole screen
+ final ValueNotifier<DateTime?> _lastSavedAt = ValueNotifier(null);
+ if (mounted) _lastSavedAt.value = now; // repaints one Text only
// in build():
+ ValueListenableBuilder<DateTime?>(valueListenable: _lastSavedAt, builder: ...)
// notes_list_screen.dart
+ _NoteTile(key: ValueKey(notes[i].id), ...)
upsert itself. Persist-on-every-keystroke is a core promise of the app — we removed the amplification around each write, not the write. Measured on a 4K/144 Hz desktop, the UI-thread build time stays ≤ 3.3 ms; the app's 100% test coverage held across all changed files.
Five questions on the reasoning. Score: 0 / 0