Skip to content
TwinScope0.3.10

Limits and performance

Every guard, cap and threshold in the app, with the source file each one lives in — and the performance numbers as measured, not as hoped.

Guards exist so that a pathological input answers instead of hanging. Every number below is a constant in the source, not a rule of thumb.

Input intake#

LimitValueWhereWhat happens
Inline over IPC10 MBsrc/main/input.tsAbove it only the path crosses IPC; the engine worker reads the file itself, so multi-megabyte strings never travel between processes
Large-input confirmation> 10 MBsrc/renderer/src/components/home/DetectedBar.tsxYou are asked before it runs, with the size shown, rather than being given a spinner that looks like a hang. Skipped for the folder and image engines
Detection sniff8192 bytessrc/main/input.tsEnough to detect the type and the encoding without reading the whole file
Raw bytes to the renderer64 MBsrc/main/input.tsOnly the image path uses this. Past it the file is refused with its size in the message
Binary NUL checkfirst 8192 characterssrc/engines/encoding.tsOf the decoded text — see encodings
UTF-16 heuristic floor32 bytessrc/engines/encoding.tsBelow it the heuristic declines to guess and returns UTF-8
UTF-16 heuristic samplefirst 1024 bytessrc/engines/encoding.ts

Text and code#

LimitValueWhereWhat happens
Line count200,000 per sidesrc/engines/text/textDiff.tsThe guard trips when the two sides together exceed 400,000 lines. The comparison stops with the actual line count in the message instead of hanging; streaming for very large files is a later release
Intra-line word diff2000 characterssrc/engines/text/textDiff.tsWord-level marking is quadratic, so it is skipped on longer lines — the line still shows as modified
Fold threshold8 linessrc/engines/text/textDiff.tsUnchanged runs longer than this collapse
Fold context3 linessrc/engines/text/textDiff.tsKept at each end of a folded run
Pairing similarity0.34src/engines/text/textDiff.tsBelow it a delete and an add are not paired into one modified row
Row height20 px--dd-row-heightFixed, so the virtualiser needs no measurement pass

JSON#

LimitValueWhereWhat happens
Depth100 levelssrc/engines/json/jsonDiff.tsStops descending, and says so: the row is annotated deeper than 100 levels — not compared
Nodes500,000src/engines/json/jsonDiff.tsStops after the cap with the note the rest is not shown
Large array10,000 itemssrc/engines/json/jsonDiff.tsArrays longer than this are matched by identity only, not by content, and a normalisation note names how many

Folders#

LimitValueWhereWhat happens
Entries50,000 per sidesrc/engines/folder/folderDiff.tsThe scan goes partial rather than making you wait, and says so
Depth40 levelssrc/engines/folder/folderDiff.tsStops descending — a tree this deep is either generated or a symlink loop that was not spotted
Symlinksnever followedsrc/engines/folder/folderDiff.tsCounted and reported as links are not followed; following them could hang the walk on a cycle
Progress granularityevery 500 entriessrc/engines/folder/folderDiff.tsOften enough to feel live, rare enough to be free

Images#

LimitValueWhereWhat happens
Longest side4096 pxsrc/engines/image/pixelDiff.tsLarger images are downscaled before comparing, with a normalisation note saying so
Zoom ceiling400%src/renderer/src/lib/imageZoom.ts
Zoom hard floor1%src/renderer/src/lib/imageZoom.tsAt 1% a 4096 px canvas is 41 px wide — nothing below that is a view of anything
Zoom stops5, 10, 25, 33, 50, 66, 100, 150, 200, 300, 400%src/renderer/src/lib/imageZoom.tsThe ladder the − / + buttons walk. The current fit is spliced in at runtime, so stepping out of a fitted view always passes back through it
Fitcapped at 100%src/renderer/src/lib/imageZoom.ts"Fit" never magnifies — a 16 × 16 favicon filling the window is not what anyone means by fitting it

The zoom floor is relative to fit, not flat: 25% of a 4000 px pair is 2000 px of pane in a 900 px stage, so a flat minimum would leave a view of both images unreachable however many times you clicked.

Storage and window#

LimitValueWhere
History rows500src/main/history.ts — oldest unstarred rows pruned past it
Palette recents8src/renderer/src/components/CommandPalette.tsx
Engine worker restarts3src/main/engine-host.ts — then you are told to restart the app
Window default size1440 × 900src/main/window.ts
Window minimum size1080 × 640src/main/window.ts
Saved geometry sanity floor320 × 240src/main/settings.ts — anything smaller is treated as a corrupt file

Measured performance#

From the project's own performance budgets, measured 2026-08-12 on the owner's Mac against an unpackaged build unless the row says otherwise. Budgets were set first; these are what the app actually did.

MetricBudgetMeasured
Cold start → interactive Compare screen< 1.5 s294 ms
1 MB text pair, end to end< 300 ms140 ms
100k-line text pair< 3 s, UI responsive195 ms
Folder scan + status, 10k files< 5 s warm820 ms for 3k × 2 (~2.7 s extrapolated)
4K PNG pair, pixel diff< 800 ms23 ms (pixel pass; decode excluded)
Scroll on a 50k-row result60 fps60 fps — median frame 16.7 ms, p95 16.7 ms, 52 rows in the DOM
Idle RAM, one comparison open< 300 MB592 MB across all processes / 204 MB renderer alone, packaged⚠️
macOS installer< 130 MB116 MB arm64, 118 MB x64

How to read those numbers honestly#

The scroll figure is a real instrument rather than an assertion shaped like one: it samples requestAnimationFrame deltas over 120 frames while the diff is actually scrolling, and prints the distribution. Its automated assertion is deliberately a floor, not the budget — frame pacing on a shared CI box is not the owner's machine, and a test that fails because another process got the CPU teaches nothing. The number worth reading is the printed p95; the assertion only catches a collapse, such as a virtualiser that started measuring every row.