Debugging the ChatGPT Mac App's Keyboard Input Lag
A deep dive into SwiftUI performance profiling and AttributeGraph pathologies
I'd finish typing a sentence and then watch the characters continue appearing for another few seconds, my fingers idle while the app caught up. The ChatGPT Mac app had developed a noticeable input lag problem, and I wanted to understand why.
What followed was a few hours of profiling, hypothesis testing, and ultimately discovering a classic SwiftUI performance antipattern buried in the traces. Here's what I found.
The Symptom
Typing in the main chat window was unusably slow. Each keystroke would buffer, then characters would appear sequentially with visible delays. We're talking 100-150ms per character—enough to completely destroy the typing experience.
But here's what made it interesting: the lag wasn't universal.
Narrowing It Down
Before diving into Instruments, I ran some quick behavioral tests:
| Scenario | Lag? |
|---|---|
| Main chat window, new conversation | Yes, severe |
| Popup quick-chat window | No |
| Message search bar | No |
| Apple Notes, TextEdit | No |
| Pasting large text blocks | No |
| Making the window smaller | Lag improves |
| Making the window larger | Lag worsens |
The window-size correlation was the first real clue. The popup chat (which has a simpler UI) worked fine. Pasting was instant, which ruled out any text-processing bottleneck—this was purely a keystroke-triggered render issue.
I also noticed that switching between conversations was slow (~1 to 3 seconds, depending on the size of the chat and the number of snippets), regardless of window size. Something expensive was happening on UI state changes.
Into the Profiler
I captured several traces using Instruments' Time Profiler. Here's a representative sample from typing in the main window:
621.00 ms 100.0% ChatGPT
317.00 ms 51.0% NSApplicationMain
...
309.00 ms 49.7% CFRunLoopRunSpecific
308.00 ms 49.5% CA::Transaction::commit()
303.00 ms 48.7% NSDisplayCycleFlush
238.00 ms 38.3% -[NSWindow layoutIfNeeded]
238.00 ms 38.3% -[NSView layoutSubtreeIfNeeded]
...
180.00 ms 28.9% -[NSView updateConstraintsForSubtreeIfNeeded]
180.00 ms 28.9% [constraint solving, 8+ nested calls]
167.00 ms 26.8% AG::Graph::update_attribute
90.00 ms 14.4% AGGraphGetWeakValue
Nearly 30% of the frame time was spent in updateConstraintsForSubtreeIfNeeded, with deeply nested calls descending into AttributeGraph (AG::).
For those unfamiliar: AttributeGraph is SwiftUI's internal dependency tracking system. It maintains a directed graph of all view dependencies and handles invalidation/recomputation when state changes. When you see AG::Graph::update_attribute and AG::Subgraph::update dominating profiles, it means the reactive graph is doing expensive recalculations.
The Hang Log
Instruments also captured a series of brief unresponsive periods:
| Start | Duration | Type |
|---|---|---|
| 00:09.090 | 126ms | Brief Unresponsiveness |
| 00:09.219 | 168ms | Brief Unresponsiveness |
| 00:09.392 | 107ms | Brief Unresponsiveness |
| 00:09.502 | 140ms | Brief Unresponsiveness |
| 00:09.646 | 156ms | Brief Unresponsiveness |
These align perfectly with individual keystrokes. Each character typed was triggering a hang long enough to be flagged by the system.
The Conversation-Switch Trace
Switching between conversations showed a similar but even more dramatic pattern:
1.22 s 100.0% ChatGPT
...
570.00 ms 46.6% Publishers.Throttle.Inner.emitToDownstream()
570.00 ms 46.6% Subscribers.Sink.receive(_:)
570.00 ms 46.6% swift_setAtReferenceWritableKeyPath
570.00 ms 46.6% _swift_release_dealloc
348.00 ms 28.5% -[NSView layoutSubtreeIfNeeded]
...
183.00 ms 14.9% -[NSView systemLayoutSizeFittingSize:...]
181.00 ms 14.8% -[NSView updateConstraintsForSubtreeIfNeeded]
There's a Combine Throttle publisher involved, but the throttling isn't helping—the downstream work is still triggering full constraint recalculations.
What's Actually Happening
Piecing together the evidence:
Every keystroke triggers a layout pass. The text input's state change propagates through SwiftUI's dependency graph.
The layout pass is expensive because it recalculates constraints for a large view subtree. The traces show
NSCollectionViewand layout calculations for what's likely the message list.The cost scales with window size. Larger windows mean more visible cells in the collection view, more constraints to solve.
The dependency graph has an inappropriate edge. The input field's text content shouldn't trigger message-list layout, but something is connecting them.
Looking at the view properties being tracked, I saw things like MessageInputButtonStyle, MessageInputTitledToggle, and MessageInputAttachmentsPart being updated thousands of times. The input area appears to have a complex SwiftUI view hierarchy that's over-coupled to external state.
The AttributeGraph Problem
SwiftUI's AttributeGraph is powerful but unforgiving. A single @State or @ObservedObject that's read by too many views can cause cascading invalidations. In this case, the likely culprit is something like:
// Hypothetical problematic pattern
struct ChatView: View {
@ObservedObject var conversation: Conversation
var body: some View {
VStack {
MessageList(messages: conversation.messages)
MessageInput(text: $conversation.draftText) // Problem!
}
}
}
If Conversation is an ObservableObject and draftText is a published property alongside messages, then every keystroke in the input field invalidates the entire view—including MessageList. SwiftUI is smart enough to diff and skip unchanged cells, but it still has to check, and the checking involves constraint recalculation.
The fix in SwiftUI would typically be to break the observation dependency:
// Better: separate the draft state
struct ChatView: View {
@ObservedObject var conversation: Conversation
@State private var draftText = "" // Local state, doesn't invalidate MessageList
var body: some View {
VStack {
MessageList(messages: conversation.messages)
MessageInput(text: $draftText)
}
}
}
Or use @StateObject scoping, extract subviews with their own observation boundaries, or use the newer @Observable macro which has finer-grained tracking.
Why Pasting Works
An interesting detail: pasting large text blocks was fast, while typing the same text character-by-character was slow.
This makes sense. Pasting is a single state mutation that triggers one layout pass. Typing "hello" is five mutations, five layout passes, five 120ms hangs. The per-keystroke overhead dominates.
What I'd Fix (If I Had the Source)
Without access to ChatGPT's source code, I can't implement a fix. But here's what I'd try:
Audit observation boundaries. Find where the input field's text state is stored and ensure it's not part of a larger
ObservableObjectthat also holds message data.Use
@Statefor draft text. Keep the in-progress message as local view state until the user actually sends it.Debounce or throttle constraint updates. If the app needs to show a preview or character count, do it with a debounced binding rather than on every keystroke.
Profile with
Self._printChanges(). SwiftUI's built-in diagnostic would show exactly which views are being invalidated on each keystroke.Consider
NSViewRepresentablefor the text input. If SwiftUI's overhead is unavoidable, wrapping anNSTextViewdirectly might bypass the reactive graph entirely for text input.Check for accidental identity changes. Sometimes views get recreated instead of updated due to unstable
idvalues inForEachor conditional view hierarchies.
Takeaways
This debugging session reinforced a few SwiftUI lessons:
Observation scope matters enormously. A single over-broad
@Publishedproperty can tank performance.AttributeGraph is usually fast, but it's O(affected views). Keep your dependency graph shallow and well-partitioned.
Window-size-dependent lag almost always means layout/constraint work. If your app gets slower as it gets bigger, you're doing too much work per frame.
Time Profiler is your friend. The
AG::symbols andupdateConstraintsForSubtreeIfNeededbreadcrumbs tell you exactly where to look.
If you're building SwiftUI apps, especially ones with text input and dynamic lists on the same screen, profile early. The reactive model is elegant but has sharp edges.
Have you seen similar issues in SwiftUI apps? I'd love to hear how others have approached input-field performance. Find me on X.