Bluetooth mice on Linux: 33Hz by default, because nobody's job is to fix it

My BLE mouse stuttered because the kernel asks it for coordinates every 30ms - a default written for heart rate straps. Four lines in main.conf took it to 141Hz. The kernel, BlueZ and the mouse each have a defensible reason not to fix this.

My Bluetooth mouse felt like it was dragging. Not disconnecting, not dropping input — just moving in small steps instead of smoothly, and noticeably worse after I plugged in a 4K display. A Logitech Signature M650 on Arch, kernel 6.18 LTS, KDE Plasma on Wayland.

The mouse was fine. Linux was asking it for coordinates 33 times a second.

Where the number comes from

$ sudo cat /sys/kernel/debug/bluetooth/hci0/conn_min_interval
24        # 24 × 1.25ms = 30ms
$ sudo cat /sys/kernel/debug/bluetooth/hci0/conn_max_interval
40        # 40 × 1.25ms = 50ms

BLE connection intervals are counted in 1.25ms units, so that window is 30–50ms — between 20 and 33 position updates per second. On a 60Hz screen you get a fresh coordinate every second or third frame. The pointer has no choice but to step.

The 4K display made it worse for a reason unrelated to Bluetooth: the same hand movement crosses more than twice as many physical pixels, so each step is twice as far.

Nobody's job

Those two values are the kernel's defaults, from net/bluetooth/hci_core.c. They were chosen when BLE first landed, for the devices BLE was actually built for — heart rate straps, beacons, sensors that report every few seconds. LE stands for Low Energy; high-frequency input was never the design target.

The spec's answer is that the peripheral should ask for something better. A device that wants a faster interval sends an L2CAP Connection Parameter Update Request, and the host grants it. Plenty of BLE mice — the M650 included — never send one.

So all three parties defer, politely, forever:

Party

Position

Kernel

I ship a conservative default; policy belongs in userspace

BlueZ

I don't impose policy; per spec the device should request what it needs

Mouse

The host should hand me something sensible (and I'd rather not ask)

Each position is defensible on its own. Together they add up to nobody owning the problem.

Windows and macOS don't hit this because their Bluetooth stacks are vertically integrated: recognise a HID device, hand it latency-oriented parameters, done. The typical values are 7.5–11.25ms. That's worth being clear about — the change below isn't a clever optimisation, it just brings Linux up to the factory default on the other two platforms.

The fix

/etc/bluetooth/main.conf, under [LE]. Connection intervals are in 1.25ms units, supervision timeout in 10ms units:

[LE]
MinConnectionInterval=6          # 7.5ms — the floor the BLE spec allows
MaxConnectionInterval=12         # 15ms
ConnectionLatency=0              # peripheral may not skip connection events
ConnectionSupervisionTimeout=42  # 420ms
sudo systemctl restart bluetooth.service

BlueZ pushes these into the kernel via MGMT_LOAD_DEFAULT_PARAMETERS *before* the adapter powers on, which is exactly when they need to be there.

I tried the other route first — a systemd unit writing to /sys/kernel/debug/bluetooth/hci0/conn_*_interval at boot. It works, but it's the long way round: debugfs is a debugging interface with no ABI stability promise, and the unit has to spin waiting for hci0 to appear. main.conf gets the timing right by construction. Use it.

Verifying it actually took

Reading the values back only tells you what the kernel currently holds, not who put it there:

sudo cat /sys/kernel/debug/bluetooth/hci0/conn_{min,max}_interval

To prove BlueZ is the one doing it — rather than a leftover from something you tried earlier — set a different value, restart, and watch it get overwritten:

echo 40 | sudo tee /sys/kernel/debug/bluetooth/hci0/conn_max_interval
sudo systemctl restart bluetooth.service
sudo cat /sys/kernel/debug/bluetooth/hci0/conn_max_interval   # back to 12
The kernel enforces min ≤ max on write. If you're poking these by hand, raise max before lowering min, or the write gets rejected with Invalid argument and you'll think the interface is broken.

Measured with the script at the end of this post:

before:  median interval 30.00ms  ->   33 Hz
after:   median interval  7.06ms  ->  141 Hz

What it costs, and where the ceiling is

It only applies to new connections. Toggle the mouse's power switch after restarting the service, or nothing changes.

It's global to `hci0`. Every BLE device on the adapter gets the same parameters. My Bluetooth keyboard benefited too, which was fine — but if you have a battery-sensitive sensor paired, it's included whether you want it or not.

Don't lock Max to 6 as well. With two BLE devices sharing one controller's airtime, pinning both to the minimum leaves the scheduler no room to fit them, and you get dropped packets and disconnects. The 6–12 range is where the stability comes from.

Battery. The M650 is rated for two years. At 7.5ms it will very likely be closer to one. If that bothers you, 12/24 gives you 15–30ms — roughly 60Hz — for a fraction of the drain.

You're already at the floor. Writing 5 or 4 is rejected outright: 7.5ms is the minimum connInterval the BLE spec defines, not a conservative kernel choice. There is nothing below it to unlock.

And there's no point going further anyway. 141Hz is already above the display's refresh rate. Swapping to a Logi Bolt receiver won't help either — Bolt is typically 125Hz, which is lower. Its advantage is interference resistance, not speed.

The aftershock: the pointer got slippery

Once the report rate went up, the pointer felt hard to control — overshooting everything. This was not a new problem. It was the first fix landing.

libinput's default adaptive acceleration profile scales pointer movement by how fast you're moving, and it estimates speed from delta / time. At 33Hz the sampling was too sparse to catch the peaks, so it systematically underestimated speed and the acceleration curve never fully engaged. The PointerAcceleration=-0.400 I'd settled on months ago was a value tuned against a crippled curve.

At 141Hz the speed estimate became accurate and the curve worked properly for the first time. Same setting, completely different behaviour.

I switched to the flat profile — pointer movement in a fixed ratio to hand movement, no speed-dependent scaling. In ~/.config/kcminputrc:

[Libinput][1133][45098][Logitech Signature M650]
PointerAcceleration=-0.400
PointerAccelerationProfile=2      # 2 = flat, 1 = adaptive
kwriteconfig6 --file kcminputrc --group Libinput --group 1133 --group 45098 \
  --group "Logitech Signature M650" --key PointerAccelerationProfile 2
qdbus6 org.kde.KWin /KWin reconfigure
On KDE 6 the binary is qdbus6. Older guides say qdbus, which will give you command not found. If you'd rather not care: dbus-send --session --dest=org.kde.KWin --type=method_call /KWin org.kde.KWin.reconfigure.

1133 and 45098 are the vendor and product IDs — copy the group names from whatever is already in your kcminputrc rather than guessing.

Three things to expect. Under flat, PointerAcceleration no longer parameterises a curve, it's a plain multiplier, so a value tuned for adaptive will feel slow — try -0.2 or 0. Slow movements get faster (adaptive was deliberately damping them for precision) and fast flicks get slower (no acceleration helping you cross the screen), which takes a day or two to stop noticing. And the payoff is that feel is now decoupled from report rate and DPI: change the Bluetooth parameters again, swap mice, adjust DPI, and you won't be re-deriving this number.

Use the GUI for the actual speed value — System Settings → Input Devices → Mouse. You'll try it a dozen times, and the slider is much faster than the config round-trip.

Measuring it yourself

Save as mousehz.py, run as sudo python3 mousehz.py /dev/input/eventN. Find the event number with grep -A5 "M650" /proc/bus/input/devices. Move the mouse in circles continuously for about ten seconds while it samples.

#!/usr/bin/env python3
"""Sample SYN_REPORT intervals on an evdev device to get the real report rate."""
import struct, sys, time, select

FMT = "llHHi"           # timeval(2x long) + type + code + value
SZ = struct.calcsize(FMT)
EV_SYN, SYN_REPORT = 0x00, 0x00

path = sys.argv[1]
want = int(sys.argv[2]) if len(sys.argv) > 2 else 300
timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 20.0

stamps = []
deadline = time.time() + timeout
with open(path, "rb", buffering=0) as f:
    while len(stamps) < want and time.time() < deadline:
        if not select.select([f], [], [], 0.5)[0]:
            continue
        data = f.read(SZ * 64)
        for i in range(0, len(data) - SZ + 1, SZ):
            sec, usec, typ, code, _ = struct.unpack(FMT, data[i:i + SZ])
            if typ == EV_SYN and code == SYN_REPORT:
                stamps.append(sec + usec / 1e6)

if len(stamps) < 10:
    print(f"Too few samples ({len(stamps)}) — was the mouse actually moving?")
    sys.exit(1)

gaps = [(stamps[i+1] - stamps[i]) * 1000 for i in range(len(stamps)-1)]
gaps = [g for g in gaps if g < 500]      # drop the pauses
gaps.sort()
n = len(gaps)
med = gaps[n // 2]
print(f"samples  : {n} intervals")
print(f"median   : {med:6.2f} ms   ->  ~{1000/med:5.1f} Hz")
print(f"min / max: {gaps[0]:6.2f} / {gaps[-1]:6.2f} ms")
print(f"p10 / p90: {gaps[n//10]:6.2f} / {gaps[n*9//10]:6.2f} ms")

Read the median, not the minimum. A p10 near zero is normal and doesn't mean you've achieved a 1000Hz mouse — a single BLE connection event can carry several HID reports, so they arrive in clusters.

No comments yet