Back to Blog
March 6, 20267 min readmacOSSystemPerformance

The Invisible Complexity of Clipboard Monitoring

From 0.5s to 15s — How Deck makes clipboard monitoring both responsive and power-efficient through adaptive polling, content parsing, OOM protection, and IDE integration.

From 0.5s to 15s — How Deck Makes Clipboard Monitoring Both Responsive and Power-Efficient


The core of clipboard monitoring on macOS is straightforward: poll NSPasteboard.general.changeCount on a timer, check if the value changed, read the new content. A weekend prototype can get this working.

But a clipboard manager that runs 10+ hours a day needs to handle things the prototype ignores — battery impact from constant polling, 200 MB screenshots that blow up memory, Figma's non-standard clipboard format, and IDE source tracking across five editor families. This post covers how Deck handles these problems: adaptive polling, content-type parsing, OOM protection, IDE integration, and Figma-aware clipboard detection.


1. Adaptive Polling: Doing Less When It Matters

The naive approach is a fixed-interval timer — say, every 0.5 seconds. That's responsive, but it's also 7,200 wakeups per hour. On a laptop running on battery, that's unacceptable.

Deck solves this with an adaptive polling mechanism that dynamically adjusts the interval based on three signals: user activity, power state, and thermal pressure.

The Bounds

Constant Value Purpose
minInterval 0.5s Fastest poll rate (active use) / 最快轮询间隔(活跃使用时)
maxInterval 2.0s Normal ceiling / 正常最大间隔
idleSecondsThreshold 8.0s Idle detection threshold / 用户空闲判定阈值
idleMaxInterval 15.0s Ceiling when user is idle / 空闲时最大间隔
thermalMaxInterval 10.0s Ceiling under thermal pressure / 热状态下最大间隔
pollBoundsCacheTTL 1.0s Cache TTL for computed bounds / 轮询边界缓存有效期

The core function effectivePollBounds() computes the current min/max interval by layering these conditions:

swift
func effectivePollBounds() -> (min: TimeInterval, max: TimeInterval) {
    var effectiveMin = minInterval  // 0.5s
    var effectiveMax = maxInterval  // 2.0s

    // Low Power Mode: widen the interval significantly
    if ProcessInfo.processInfo.isLowPowerModeEnabled {
        effectiveMin = min(effectiveMin * 2.0, 2.0)
        effectiveMax = max(effectiveMax * 4.0, 8.0)
    }

    // Thermal pressure: back off further
    if thermalState == .serious || thermalState == .critical {
        effectiveMin = max(effectiveMin, 1.0)
        effectiveMax = max(effectiveMax, thermalMaxInterval) // 10s
    }

    // User idle: stretch the max
    if secondsSinceUserInteraction() > idleSecondsThreshold {
        effectiveMax = max(effectiveMax, idleMaxInterval) // 15s
    }

    return (effectiveMin, effectiveMax)
}

When the clipboard changes, the interval snaps back to min immediately. When nothing happens, it backs off by 1.5× on each tick, up to the computed max. So when the user is actively copying, the poll rate stays at 0.5s; after 8 seconds of inactivity, it gradually stretches to 15s.

swift
func adjustPollInterval(success: Bool) {
    let bounds = effectivePollBounds()
    if success {
        pollInterval = bounds.min
    } else {
        pollInterval = min(pollInterval * 1.5, bounds.max)
    }
}

Timer Architecture

The timer runs on a dedicated DispatchQueue at .utility QoS, not on the main thread:

swift
let pollQueue = DispatchQueue(label: "deck.clipboard.poll", qos: .utility)
let pollTimer = DispatchSource.makeTimerSource(queue: pollQueue)

A 25% leeway is applied (clamped to 50–500ms), letting macOS coalesce timer wakeups with other system timers. This reduces the actual wakeup frequency noticeably on battery.

Power State Awareness

Deck listens to NSProcessInfoPowerStateDidChange to detect Low Power Mode transitions. Beyond adjusting poll intervals, it also throttles downstream work — for example, link previews skip image fetching when running on battery:

swift
let includeImage = !ProcessInfo.processInfo.isLowPowerModeEnabled
LinkMetadataService.shared.prefetch(for: url, includeImage: includeImage)

2. ⌘C Interception: Bypassing the Poll Cycle

Polling is inherently reactive. Even at 0.5s intervals, there's a perceptible delay. Deck eliminates this by intercepting ⌘C/⌘X globally and triggering a clipboard check immediately.

swift
func installCopyMonitor() {
    NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
        guard event.modifierFlags.contains(.command) else { return }
        let keyCode = event.keyCode
        // 0x08 = C, 0x07 = X
        guard keyCode == 0x08 || keyCode == 0x07 else { return }

        self.pendingCopyCheck?.cancel()
        self.pendingCopyCheck = Task {
            try? await Task.sleep(nanoseconds: 80_000_000) // 80ms
            await self.checkForChanges()
        }
    }
}

The 80ms delay is deliberate: the source app needs a moment to actually write data to NSPasteboard. Cancel-and-reschedule ensures rapid consecutive ⌘C presses don't spawn redundant checks.


3. Content Type Resolution: The Priority Ladder

NSPasteboard is not a single-value container. A single copy operation can deposit dozens of representations — PNG, TIFF, RTF, HTML, plain text, private app types — and the clipboard manager must pick the most meaningful one.

Deck defines an explicit priority order:

swift
// PasteboardType.supportedTypes (highest → lowest)
.png, .tiff, .jpeg, .heic, .heif, .gif, .webp, .bmp,  // Images
.rtfd, .flatRTFD, .rtf,                                  // Rich text
.fileURL,                                                 // Files
.publicURL,                                               // URLs
.string                                                   // Plain text

Images take the highest priority because they're the most information-dense and hardest to reconstruct. Plain text sits at the bottom — it's almost always available as a fallback.

The Three-Level Text Fallback Chain

When the primary type can't be parsed, Deck doesn't give up. It walks a three-level fallback chain:

  1. resolveHTMLTextPayload() — Checks for HTML content or Figma markup first.

  2. resolveUTF16PlainText() — Tries public.utf16-external-plain-text, then public.utf16-plain-text.

  3. resolveGenericTextPayload() — Iterates all types conforming to UTType.text.

  4. resolveHTMLTextPayload()

  5. resolveGenericTextPayload()

An exclusion list of ~20 types (including .string, .rtf, .html, .pdf, various WebKit/Chromium internal types, and macOS system types) prevents the fallback chain from re-processing types that were already tried or are known to be noise.

A practical optimization: when an RTFD item also carries a plain text representation, Deck prefers the plain text — avoiding the overhead of RTFD decoding when the content is textual anyway.


4. OOM Protection: Defending Against the 200 MB Screenshot

A clipboard manager that crashes when you copy a large image is worse than no clipboard manager at all. Deck implements multi-layered OOM protection with escalating thresholds:

Threshold Value What it guards
maxPerTypeBytes 1 MB Per-type snapshot cap / 单类型快照上限
maxSnapshotBytes 4 MB Total snapshot byte limit / 粘贴快照总字节上限
maxInlineImagePasteBytes 25 MB Beyond this, skip inline image bytes if file URL exists / 超过此值且有文件 URL 时不写入图片字节
largeBlobThreshold 512 KB Data above this is stored as a separate blob file / 超过此值存为独立 Blob 文件
maxImageDataSize 100 MB Images above this are never loaded directly / 超过此值的图片不直接加载

The 512 KB blob threshold is particularly interesting. Instead of storing large data inline in SQLite, Deck writes it to a separate file via BlobStorage.shared.storeAsync and stores only the path in the database. This keeps the database lean and prevents SQLite page fragmentation from large BLOBs.

For images exceeding 100 MB, resolvedData() returns nil outright — the image is simply too large to hold in memory. For display purposes, thumbnail() uses CGImageSourceCreateThumbnailAtIndex to generate a thumbnail via progressive decoding, never loading the full bitmap:

swift
func generateSafeThumbnailFromData(_ data: Data, maxDimension: CGFloat) -> NSImage? {
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimension,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: false
    ]
    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
    else { return nil }
    return NSImage(cgImage: thumbnail, size: .zero)
}

5. IDE Source Anchor Tracking: Know Where It Came From

Example: you copied a snippet from VS Code 20 minutes ago. You want to paste it, but you also want to jump back to where you copied it from.

Deck captures a SourceAnchor at copy time — the file path, line number, column, and IDE identity — so you can navigate back with a single click.

swift
struct SourceAnchor {
    var ide: IDEKind
    var filePath: String
    var line: Int?
    var column: Int?
    var sourceBundleId: String?
    var captureMethod: String?
    var capturedAt: Int64?
    var openHint: IDEOpenHint?
}

Supported IDEs

IDE Bundle ID(s)
VS Code com.microsoft.VSCode, com.microsoft.VSCodeInsiders
Cursor com.todesktop.230313mzl4w4u92, com.anysphere.cursor, com.cursor.Cursor
Windsurf com.exafunction.windsurf
Xcode com.apple.dt.Xcode
JetBrains IDEs com.jetbrains.*

How It Works

Source identification starts with org.nspasteboard.source — a pasteboard type that some apps (especially IDE-adjacent tools) write. If that's missing, Deck falls back to sourceApp.bundleIdentifier.

The file path and cursor position come from the macOS Accessibility API. Deck queries the focused UI element for:

  • kAXInsertionPointLineNumberAttribute → cursor line number
  • kAXSelectedTextRangeAttribute + kAXLineForIndexParameterizedAttribute → selection start line
  • kAXRangeForLineParameterizedAttribute → column calculation

Different IDEs have different Accessibility tree depths, so Deck uses tuned search budgets:

IDE Family maxDepth maxNodes
VS Code / Cursor / Windsurf 8 2000
Xcode / JetBrains 4 200

Jump Back

Each IDE family has its own mechanism for "open file at line":

  • VS Code / Cursor / Windsurf: code -g file:line:column or vscode://file/... URL scheme
  • Xcode: xed -l line filePath
  • JetBrains: http://localhost:63342/api/file/... (local REST API)

6. Figma Clipboard Detection

Figma runs as a Chromium-based desktop app, and its clipboard behavior reflects that. When you copy a frame or component in Figma, the pasteboard receives what looks like standard HTML — but embedded inside it is base64-encoded design data wrapped in custom (figma) / (figmeta) markers. Most clipboard managers treat this as HTML or plain text, stripping the Figma-specific data entirely. When you paste it back, the design structure is gone — you get a flat image or garbled text instead of the original editable component.

Deck detects Figma content via containsFigmaMarker(in:), which scans the HTML for these markers. Once detected, a multi-step extraction pipeline kicks in:

  1. resolveFigmaPayload() — Entry point; only runs for unresolved types.

  2. extractFigmaPayload() — Searches UnsupportedPasteboardPayload.items for Figma HTML.

  3. extractFigmaPayloadFromPlistFallback() — Falls back to plist parsing if needed.

  4. buildFigmaPayload() — Extracts (figma) and (figmeta) base64 data from the HTML.

  5. resolveFigmaPayload()

  6. extractFigmaPayloadFromPlistFallback()

  7. buildFigmaPayload()

The extracted metadata is structured as:

swift
struct FigmaClipboardMeta {
    var fileKey: String
    var pasteID: String
    var dataType: String
}

struct FigmaClipboardPayload {
    var html: String
    var figmaBase64: String
    var figmetaBase64: String
    var meta: FigmaClipboardMeta
    var sourceURL: String?  // from org.chromium.source-url
}

A critical design decision: once Figma content is detected, Deck skips the text fallback chain entirely. The structured Figma data is preserved intact, rather than being flattened into a plain text representation that would lose all design semantics.


7. The Full Monitoring Loop

Putting it all together, here's what happens on every clipboard check:

sequenceDiagram
    participant Timer as DispatchSourceTimer
    participant Key as ⌘C Monitor
    participant Check as checkForChanges()
    participant PB as NSPasteboard
    participant Filter as Skip Filters
    participant Parse as Content Parser
    participant IDE as IDEAnchorService
    participant Store as DeckDataStore

    alt Timer fires
        Timer->>Check: poll interval elapsed
    else ⌘C / ⌘X detected
        Key->>Check: after 80ms delay
    end

    Check->>Check: guard !isCheckingForChanges (re-entrancy lock)
    Check->>PB: read changeCount
    PB-->>Check: currentChangeCount

    alt changeCount unchanged
        Check->>Check: adjustPollInterval(success: false) → interval × 1.5
    else changeCount changed
        Check->>Filter: apply skip filters
        Note over Filter: • Deck's own writes (deckPasteboardType)<br/>• Recently pasted content<br/>• Sensitive / concealed items<br/>• Ignored applications

        alt filtered out
            Filter-->>Check: skip
        else passes filters
            Check->>Parse: ClipboardItem(with:pasteboard:)
            Note over Parse: type priority resolution<br/>+ text fallback chain<br/>+ Figma detection<br/>+ OOM guards

            Parse-->>Check: clipboardItem

            Check->>IDE: captureAnchor(for: sourceApp)
            IDE-->>Check: SourceAnchor?

            Note over Check: optional post-processing:<br/>steganography decode,<br/>smart line-joining,<br/>link preview prefetch

            Check->>Store: addParsedItemWithRules(item)
            Check->>Check: adjustPollInterval(success: true) → min interval
        end
    end

Wrapping Up

Most of these systems interact with each other. Thermal pressure changes the poll interval, which changes how quickly ⌘C interception matters. A Figma paste can carry 20+ pasteboard types, hitting both the content priority ladder and the OOM guards simultaneously. IDE anchor capture has to complete within the 80ms window before the next poll cycle overwrites the Accessibility state.

There are areas we're still improving. The Accessibility API can be slow on certain apps (Electron-based editors with deep DOM trees), and we're experimenting with caching strategies to reduce AX query frequency. We're also looking into using NSPasteboard observation APIs if Apple exposes more event-driven hooks in future macOS versions, which could reduce polling overhead further.


Deck is a macOS clipboard manager that keeps everything local. Learn more at deckclip.app.