I'm Hisashi. I've run my own company for 22 years, and from 2011 to 2023 I ran it while moving between countries. Running a paperless company means I scan a lot of documents, and my main scanner had developed an annoying tic: I would press Scan, and nothing would happen. The scan button just blinked, ten to fourteen times, before the rollers finally started pulling paper.
The scanner is a ScanSnap iX2500, rated at 45 pages per minute. The reading speed was never the problem. The problem was the several-second stall before a single sheet moved, and my first instinct was the usual suspect: the connection. So I switched it from WiFi to USB. Exactly the same delay. Then I switched the save destination from the pop-up menu style to a fixed folder profile. Same delay. When two things you assumed were the cause turn out to change nothing, you are looking in the wrong layer.
The delay wasn't in the scanner, and it wasn't on the wire. It was the Mac, doing something absurd, on every single scan.
The symptom
Press Scan, watch the button blink, wait. Worse, when I scanned twice in a row, the second scan was slower than the first. That detail matters. A slow-but-constant delay smells like a fixed handshake. A delay that grows when you repeat the operation smells like contention, something piling up and not clearing.
USB versus WiFi made no difference. Quick-menu versus folder profile made no difference. So the bottleneck was not data transfer and not the scanner firmware. It was something the Mac ran between "button pressed" and "paper moving," and whatever it was, it got heavier the more I used it.
Rule one: name the process
Same reflex as always. Before touching anything, find the process. I streamed the unified log filtered to the ScanSnap helper processes and sampled CPU while I triggered a scan.
One process lit up the instant I pressed the button:
PID %CPU COMMAND
3798 186.6 SshResident
SshResident is the resident background app that ScanSnap Home keeps running to watch the scanner. It should sit near idle. During the blink, it was pinned above 100%, peaking near 186%, meaning it had more than one full core to itself for the entire stall. When the CPU dropped back to idle, the paper started moving. The blink and the CPU spike were the same event.
The cause: enumerating every app, hundreds of times
CPU percentage tells you a process is busy. It doesn't tell you what it's doing. For that, macOS ships sample, which snapshots a running process's call stacks. I sampled SshResident three times during the stall. All three came back with nearly all of the CPU time in the same call chain:
-[SSUIController doScanClick:]
isAppLaunchedByBundleID
getLaunchAppProcessIDBySignature
GetProcessInformation (HIServices, the old Carbon Process Manager)
_LSCopyApplicationInformation (LaunchServices)
Here is what that stack means. When you press Scan, SshResident needs to launch a helper app to handle the scan, and before it proceeds it polls to check whether that helper has finished launching. The way it checks is GetProcessInformation, an ancient Process Manager call that does not ask about one specific app. It walks the entire list of running applications that LaunchServices is tracking and builds an information record for each one, just to find the single process it cares about. Then it does that again on the next poll. And again, hundreds of times, until the helper is up.
The cost of one sweep is proportional to how many apps LaunchServices is tracking. On a normal Mac that is maybe thirty, and nobody ever notices this code. My Mac is a working machine for eleven businesses: browsers with dozens of tabs, editors, local dev servers, MCP servers, recording tools. At the moment I measured, LaunchServices was tracking about 380 applications, and the whole machine was running roughly 1,800 processes. So every poll walked 380 apps, and the launch wait fired hundreds of polls, and that product is a full core pinned for ten to fourteen seconds. The second scan was slower because it landed while the first scan's helper teardown was still churning the same list.
The delay was never the scanner's limit. It was an O(number of apps) loop in the vendor's software, running head-on into a machine that runs an unusual number of apps.
The clever fix that backfired
Once you know it's GetProcessInformation being called in a hot poll loop, the engineer's temptation is obvious: cache it. Intercept that one function, remember each process's answer for a second or so, and let the hundreds of repeated polls hit the cache instead of rebuilding a 380-app list every time.
You can inject code into another process on macOS with DYLD_INSERT_LIBRARIES, and normally a hardened, notarized, signed system binary would refuse it outright. This one couldn't refuse. Its own code signature carried two entitlements, allow-dyld-environment-variables and disable-library-validation, precisely the two that let an unsigned or ad-hoc dylib load into it. The vendor had left the door open, presumably for its own plug-in machinery. I wrote a small interposing library that cached GetProcessInformation per process with a short expiry, ad-hoc signed it, added the injection to the helper's Info.plist, and re-signed the bundle while preserving its entitlements.
The first version crashed the daemon on launch. The root cause is a nice trap. I had hand-declared the old ProcessInfoRec struct from memory. The real 64-bit layout is not what you'd guess: it is packed to two-byte alignment, it is 72 bytes, its first pointer sits at offset 4 rather than 8, and the processAppSpec field that exists in every online reference was dropped entirely in the 64-bit version because it was built on the 32-bit-only FSSpec type. My natural-alignment struct put every field at the wrong offset, and my copy of a 264-byte buffer into what the caller had sized as a 70-byte one smashed its stack. I rebuilt the interposer against the SDK's real types, verified it in a standalone harness that mimicked the daemon's exact call pattern, confirmed it was safe and cut real calls by 80%, and redeployed.
And scanning stopped working entirely.
This is the part worth sitting with. The library was now provably correct in isolation and provably fast. But by caching the answer to "has the helper launched yet," I had blinded the daemon to the one state change it was actually polling for. It waited forever for a launch it could no longer observe. My harness reproduced the call pattern perfectly and the state transition not at all, which is exactly the class of bug a harness cannot catch. Every failed attempt left the scanner dead until I restored the original from backup, and power-cycling a scanner mid-workflow to recover from your own patch is a strong signal you are solving the wrong problem. I reverted the whole thing.
The fix that wasn't code
Step back far enough and the real question changes. I had been trying to make the slow path fast. The better move was to delete the slow path.
The delay lives entirely in the Mac-side software that orchestrates the scan. So don't let the Mac orchestrate the scan. The iX2500 has a touchscreen and, as of a 2025 firmware feature, it can save directly to a network folder over SMB with no PC involved at scan time. The scanner writes the file itself. SshResident is never in the loop, because ScanSnap Home is never in the loop.
The setup is boring, which is the point:
- Turn on File Sharing on the Mac and export the destination folder as an SMB share.
- Give the Mac a fixed IP so the scanner's saved destination never breaks. I put the wired connection (a CalDigit TS4 dock) on a static
192.168.1.10, low in the range to stay clear of the DHCP pool. - On the scanner's touchscreen, register a network-folder profile pointing at
\\192.168.1.10\ScanDatawith the Mac account's credentials. - Point the scanner's clock at an NTP server, because SMB authentication and file timestamps both care about time. The home router (a GL.iNet Flint 3 at
192.168.1.1) already answers NTP on the LAN, so that one line needs no internet at all.
Then you pick the profile on the touchscreen and scan.
Results
- Time from pressing scan to paper moving: a couple of seconds, consistently
-
SshResidentin the scan path: gone, along with the 380-app enumeration - Files land straight in a local folder on the Mac, named by timestamp, ready for whatever processing I run next
- Mac-side scanning software touched during a scan: none
Takeaways
- When the fix you assumed doesn't change the symptom, stop testing that layer. USB versus WiFi changing nothing is what told me the scanner and the wire were both innocent.
-
sampleis the tool that turns "this process is busy" into "this process is calling this exact function in a loop." CPU percentage is a smoke alarm; the call stack is the fire. - A delay that gets worse when you repeat the operation is contention, not a fixed cost. Chase the thing that accumulates.
- Injecting into a signed system binary is sometimes possible, and rarely wise. When a vendor daemon carries
allow-dyld-environment-variablesanddisable-library-validation, the OS will let you in, but you are now maintaining a patch against software that fights back on every update, and a harness that reproduces the calls will still miss the state machine. - The strongest fix is often the one that removes your code from the path instead of adding more. The scanner could already do the whole job by itself. I just had to stop insisting the Mac be in the middle.
The meta-lesson is the same one that I keep coming back to. Depend on a machine and you owe it root-cause fixes, not rituals. Sometimes the root cause is that you were using the wrong tool for the job, and the cure is to use less software, not more.
Top comments (0)