Daniel Hussey's Blog

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:

  1. Every keystroke triggers a layout pass. The text input's state change propagates through SwiftUI's dependency graph.

  2. The layout pass is expensive because it recalculates constraints for a large view subtree. The traces show NSCollectionView and layout calculations for what's likely the message list.

  3. The cost scales with window size. Larger windows mean more visible cells in the collection view, more constraints to solve.

  4. 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:

  1. Audit observation boundaries. Find where the input field's text state is stored and ensure it's not part of a larger ObservableObject that also holds message data.

  2. Use @State for draft text. Keep the in-progress message as local view state until the user actually sends it.

  3. 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.

  4. Profile with Self._printChanges(). SwiftUI's built-in diagnostic would show exactly which views are being invalidated on each keystroke.

  5. Consider NSViewRepresentable for the text input. If SwiftUI's overhead is unavoidable, wrapping an NSTextView directly might bypass the reactive graph entirely for text input.

  6. Check for accidental identity changes. Sometimes views get recreated instead of updated due to unstable id values in ForEach or conditional view hierarchies.

Takeaways

This debugging session reinforced a few SwiftUI lessons:

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.