Uncork

Why aim feels floaty on a Mac: macOS delivers the mouse once per display frame

Measured 22–25 September 2026 · MacBook Pro 14″, M3 Pro, 120 Hz ProMotion, macOS 26

Play a shooter on a Mac with a gaming mouse and sooner or later something feels off: the counter says 100+ fps, the picture looks smooth, yet the crosshair behaves as if it were on a rubber band. Small corrections overshoot or fall short. The usual explanations are “Macs aren’t for games” or pointer acceleration. Acceleration does get in the way, but the main cause is something else, and it shows up in numbers.

What we measured

We logged when mouse-moved events (NSEvent, mouseMoved) reach an app. The mouse is a Logitech reporting at 1000 Hz, one report per millisecond. The display is a 120 Hz ProMotion panel.

Expected: an event roughly every millisecond. Measured: events arrive in bursts, 8.33 ms apart — exactly the display refresh. About eight mouse reports get merged into one event carrying their summed motion. We saw the same on an idle app with nothing to slow it down and in a game.

The same mouse over the same 100 ms: 81 events from the mouse, 13 in the app
22 September: in 20 seconds of movement the mouse sent 13,452 reports; the app received 2,269 events.

We’re not the first to run into this. The sokol engine (plain C, no middle layer) has issue #1344, “Sporadic input delay on macOS Tahoe (1000 Hz mouse)”, and in the Apple Developer Forums thread “Equivalent of coalescedTouchesForTouch in AppKit?” a developer reports that on macOS 26.2 mouse motion is thinned to the display rate with no way to get the original points out of NSEvent. So this is how the system behaves, not a quirk of one game or engine.

Why a game feels it and the desktop doesn’t

On the desktop the bursts are invisible: the cursor is redrawn at the same display rate, so a burst just becomes one cursor step. A game is different. It runs at its own frame rate, and that almost never lines up with the bursts.

Take a game at 100 fps — a frame every 10 ms — and bursts every 8.33 ms. In 50 ms there are 5 frames and 6 bursts, so four frames get one burst of motion and the fifth gets two. With a steady hand the crosshair jumps twice as far on every fifth frame: twenty hitches a second (arithmetic, not a measurement). At 80 fps (12.5 ms) the pattern changes but doesn’t improve: frames alternate between one and two bursts. Raising or lowering the frame cap moves the hitches around without removing them. As long as motion arrives in display-sized portions and frames run on their own clock, the two beat against each other.

If every frame got all the mouse reports from its own time slice, motion would be proportional to frame length and the beat would disappear.

What you can do without anyone’s app

Every report, no permission: GCMouse

GameController’s GCMouse (macOS 11+) is meant for games, and it turns out not to go through the display-rate path. On 25 September we recorded the same 20 seconds three ways at once: a system event tap (hardware timestamps), NSEvent and GCMouse in one window. Logitech G309 through its receiver, 1000 Hz.

SourceEventsPer secondMedian gapP99 gapUnder 2 ms
The mouse (event tap)16,9198430.99 ms5.90 ms88.3%
GCMouse16,9218440.99 ms5.96 ms87.5%
NSEvent2,3771198.07 ms14.87 ms0.3%

The recording app had no Input Monitoring grant (IOHIDCheckAccess returned “unknown”), and no prompt appeared.

import GameController

let queue = DispatchQueue(label: "mouse", qos: .userInteractive)
NotificationCenter.default.addObserver(forName: .GCMouseDidConnect, object: nil, queue: .main) { note in
    guard let mouse = note.object as? GCMouse else { return }
    mouse.handlerQueue = queue
    mouse.mouseInput?.mouseMovedHandler = { _, dx, dy in
        // One call per device report. Raw counts, no acceleration curve;
        // dy is positive upwards, the opposite of NSEvent's deltaY.
    }
}

Every report from the device: IOHIDManager

IOKit lets you subscribe to a device’s HID values. The callback fires for every report, with relative X and Y motion and the report’s timestamp:

import Foundation
import IOKit.hid

let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
IOHIDManagerSetDeviceMatching(manager, [kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop,
                                        kIOHIDDeviceUsageKey: kHIDUsage_GD_Mouse] as NSDictionary as CFDictionary)
IOHIDManagerRegisterInputValueCallback(manager, { _, _, _, value in
    let element = IOHIDValueGetElement(value)
    guard IOHIDElementGetUsagePage(element) == UInt32(kHIDPage_GenericDesktop),
          IOHIDElementIsRelative(element) else { return }
    let usage = IOHIDElementGetUsage(element)        // kHIDUsage_GD_X or kHIDUsage_GD_Y
    let delta = IOHIDValueGetIntegerValue(value)     // raw counts, no acceleration curve
    let stamp = IOHIDValueGetTimeStamp(value)        // mach time of the report
    // X and Y of one report share a timestamp: accumulate until it changes.
}, nil)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))

Things that bit us:

Where this comes from

We make Uncork, a Mac app that runs Windows Steam games on Apple Silicon. It started with Counter-Strike 2: at 100+ fps the aim felt mushy, and no game setting helped. The measurements above come from that. Today Uncork’s input driver reads the mouse with IOHIDManager while a game holds the cursor, and behaves normally the rest of the time; the GCMouse result is why we are now checking whether the Input Monitoring prompt can go away. On our reference MacBook Pro with M3 Pro, CS2 runs at 100–120 fps on Dust II — the method is on the CS2 page.

If you’ve shipped a game or an emulator on macOS and hit the same wall, we’d like to hear how you got around it — especially whether isMouseCoalescingEnabled still helps anyone on current systems. Write to hello@uncork.win.

← Back to the main page