v0.1.12 rebuilt the results grid on AppKit. v0.1.13 turned the navigator into something you could do things from. Both shipped features; both also accumulated a small amount of latent slowness that only showed up after a long working session. v0.1.14 has no new features. It’s the work behind a single user-reported symptom: “after walking through different tables and opening some large cells to edit / view, the UI starts working very slowly.”

That sentence describes a path, not a bug. Each individual operation was fast. But the path retained more state than it needed to, and rebuilt more SwiftUI views than it had to, until the cumulative cost was visible. This release is a deep audit of that path. Sixteen distinct fixes, no behavior changes, every test still green.

The grid: stop calling reloadData() on every tick

The biggest offender lived in DataGridView.updateNSView. SwiftUI calls updateNSView whenever any observed property the parent reads changes — selection, loading flags, filter SQL, an unrelated @Observable field anywhere in TableViewModel. The old implementation unconditionally called NSTableView.reloadData(). Each reload re-asked the data source for every visible row times every visible column. Each cell was rendered through a SwiftUI closure that wrapped its content in AnyView and allocated a fresh NSHostingView root view. A 50-row by 30-column grid was rebuilding 1500 SwiftUI view trees for every selection change.

The fix is a content-revision check. QueryResult gained a revision: UUID that’s minted on every new construction and on every replacingCell mutation. The grid coordinator stores the last revision it rendered, plus the row count, columns, and sort key. reloadData() only fires when those signals actually differ. Selection-only changes — the click-a-row hot path — now go through selectRowIndexes without touching cells.

Two related fixes piggyback on this. First, the per-cell view builder used to call String.lowercased() on every column’s data type, walk a set of numeric type names, and look up FK constraints by name — per visible cell, per reload. The classification doesn’t depend on the cell’s value (mostly); it depends on the column. So column metadata (render kind, editor kind, alignment hint, FK info, formatted header) is now precomputed once in ResultsGridView.init as a [GridColumnMeta] array indexed by display column. cellView reads a single subscript instead of redoing the classification.

Second, the old grid carried a parallel identifiedRows: [IdentifiedRow] array that was a fresh enumerate-and-map of every row on every parent body pass. Reading the code revealed that nothing actually used the rows — only identifiedRows.count. The array was dead memory churn. Deleted.

Bounded caches everywhere caches accumulate

DatabaseClient caches introspection results so the second visit to a table doesn’t re-query information_schema. Eight caches, one per metadata shape (tables, views, materialized views, functions, sequences, types, columns, primary keys). They were all unbounded plain [String: T] dictionaries. A user who walked through fifty tables paid the price by retaining fifty [ColumnInfo] arrays for the whole session.

v0.1.14 ships a small BoundedLRU<Key, Value> struct in Utilities/. Each cache now has an explicit capacity (64 for object lists, 128 for column / PK lookups). get refreshes recency; eviction drops the least-recently-used entry. Same hit rate for hot tables, bounded memory.

Open object tabs got the same treatment. Each tab snapshot carries a full QueryResult (rows + columns + metadata) so switching tabs feels instant. Multiplied across dozens of tables that’s a real amount of retained String data. A static maxOpenTabs = 12 cap, with LRU eviction of the oldest non-active tab on each new open, keeps the tab strip from being a slow memory leak. The active tab is always exempt — losing the tab you’re working in would be surprising.

The field editor: debounce, defer, and stop walking the string

Opening a 10 KB JSON cell felt sluggish for three reasons.

JSON validation ran on every keystroke. .onChange(of: text) { validateJSON() } called JSONSerialization.jsonObject on the full text per character. Replaced with .task(id: text) { try? await Task.sleep(...); ... }, which cancels the in-flight validation task when the text changes again. Bursts of typing absorb into one validation round.

The character-count label walked the string. Text(“\(text.count) characters”) looks harmless — String.count is O(N) over Unicode scalars, called on every redraw of the long-text editor. The count is now cached in @State and recomputed only when text changes, using O(1) text.utf16.count.

Initial parse was synchronous on the main actor. Opening the sheet triggered setupInitialState(), which pretty-printed the JSON via JSONSerialization.data(... prettyPrinted, sortedKeys) and, for array columns, ran parsePostgresArray over the entire value. Both now run in Task.detached(priority: .userInitiated) and patch state back via MainActor.run. The sheet opens immediately with the raw value; the pretty-printed text replaces it on the next tick.

And while we were in there: parsePostgresArray was a hand-written String.Index loop. That looks O(N) but inner.index(after: i) is only amortized O(1) for ASCII strings — on mixed content (anything with multi-byte scalars) it can degrade to O(N²). Rewritten over inner.unicodeScalars, which is a forward iterator with no index arithmetic. Arrays with hundreds of elements open noticeably faster.

The inspector: stop rebuilding the column dictionary

A subtle SwiftUI trap. InspectorView had:

private var columnInfoByName: [String: ColumnInfo] {
    Dictionary(uniqueKeysWithValues: tableVM.columns.map { ($0.name, $0) })
}

The comment above it claimed the dictionary made the lookup “O(1) — the previous first(where:) was O(columns × rows).” True for a single body pass. But @Observable doesn’t memoize computed properties — SwiftUI re-evaluates them on every body call, and the inspector’s body fires whenever any of half a dozen unrelated tableVM fields change (selection, loading, page, sort). On a wide table the dictionary was being rebuilt dozens of times per second.

Now stored in @State columnInfoIndex, reseeded only when tableVM.columns.count or selectedObjectName changes. The same fix pattern applies to QueryHistoryViewModel.filteredEntries (a computed property that walked up to 500 history entries on every redraw) — converted to a memoized cache invalidated via didSet on the inputs. And to QueryViewModel.parseTableFromQuery() (anNSRegularExpression.firstMatch over the entire query text) — now memoized by trimmed text.

Smaller cuts

Once the big-ticket items are out of the way, the small ones start to matter. v0.1.14 takes a sweep at each.

O(N) String.count on every cell. The cell-decoding path in PostgresClient capped cell length at 10 000 characters with s.count > 10_000. Called once per cell decoded — 6 000 times per 200×30 page. Swapped for s.utf8.count, which is O(1) on native Swift strings.

Wide-table layout cost. The Structure tab’s SwiftUI Table set idealHeight: 120 + columns.count * 22. For a 200-column schema that asked the layout system for a 4 520-point ideal height. Table doesn’t virtualize columns, so it tried to render every row up to that height eagerly. Capped at min(N, 600); the outer ScrollView handles overflow.

Per-row Color(nsColor:) allocations. Five view files were calling Color(nsColor: .controlBackgroundColor) inline, some inside ForEach loops. Each call allocates a fresh Color wrapper. Replaced with static lets at the top of each view.

SQL syntax highlighter re-tokenizing on attribute-only edits. SQLTextStorage.processEditing ran the full document tokenizer whenever Cocoa fired the “edited” notification — including the recursive attribute writes the highlighter itself emits. Now guards on editedMask.contains(.editedCharacters); the highlighter still runs on every keystroke, but no longer on the internal re-entries.

Filter-bar pickers rebuilding their menu items per row. The Content tab’s filter rows each rendered aPicker over tableVM.columns. Three filters on a 100-column table is 300 picker items rebuilt on every redraw. The column-name array is now materialized once in filterBar and passed to each row.

Why this matters

The complaints that drive performance work are usually imprecise: “it feels slow.” Profilers will point at one or two hot frames, but the lived experience is rarely a single slow frame — it’s a death by paper cuts, where every interaction has a hint of stutter and the stutter compounds over a long session. Fixing that requires looking at the same view from many angles: what gets retained? what gets recomputed? what allocates? what runs on the main actor that shouldn’t?

Concretely, v0.1.14 means:

  • Walking through tables no longer pile up megabytes of cached QueryResult snapshots. Tab count is bounded; introspection caches are bounded.
  • Selecting rows in wide tables doesn’t rebuild the entire grid. The AppKit grid distinguishes data changes from selection changes, and only the former triggerreloadData().
  • Opening a large JSON or array cell is instant. Pretty-print and array parsing happen off the main actor; validation debounces per keystroke; the editor stops walking the string on every redraw.
  • Wide tables in the Structure tab don’t try to lay out every column up-front.

Nothing changed about what SequelPG does. Everything changed about how often it does the same work twice.

What’s next

Performance work is never finished — it’s only amortized. The grid’s SwiftUI cell content still flows through AnyView; that’s the next layer to peel, and it requires either a type-erased equatable wrapper or dropping the SwiftUI cell content entirely in favor of a hand- drawn NSTableCellView. Both are bigger changes than this release wanted to take on.

The roadmap from v0.1.13 is otherwise unchanged: richer Create Function / Procedure sheets, edit-in-place for routines, deeper FK navigation, and continued work on the catalog-driven Definition tab. The first thing that ships in v0.1.15 will likely be one of those.

SequelPG is open source on GitHub. Grab v0.1.14 from the releases page, file issues for anything that still feels slow, or send a PR. Feedback is always welcome.