Skip to content

Show the tray icon 40ms sooner - #861

Merged
SimonCropp merged 3 commits into
mainfrom
tray-startup-latency
Aug 31, 2026
Merged

Show the tray icon 40ms sooner#861
SimonCropp merged 3 commits into
mainfrom
tray-startup-latency

Conversation

@SimonCropp

Copy link
Copy Markdown
Member

The tray icon appeared 121 ms into a warm start, and most of what came before it was work nothing about the icon needed. Measured from process start with probes at each step:

step before after
.NET host → Main 23 29
TrayViewerDirectory.Register 8 2
Logging.Init 23 23
WinForms flags 5 5
settings read 43 moved after the icon
mutex + version file 3 9
images 8 3
NotifyIcon 9 10
icon visible 121 ms 81 ms

Time to fully ready is unchanged at ~210 ms, which is the honest summary: this moves the wait rather than removing much of it, and the wait it moves is the one the user is watching.

The icon before the settings file

Nothing between the two needs a setting — AlwaysKill and the hot keys are both used further down — and until that line there is nothing in the notification area to look at. Still after the mutex, so a second instance exits without ever showing an icon. Worth 33 ms.

Images decoded on first use

A static constructor decoded all eleven the moment anything touched one, and the first thing to touch one is the tray icon, where only Default is wanted. Four more are on the fixed menu items; the last five are only ever shown when something is pending.

Lazy<T> rather than field initialisers, which would not have helped — the runtime initialises a type's static fields together, so Default would still have brought the other ten with it. Thread safe because the icon is set from the scan timer as well as from the UI thread. Worth 5 ms, and it stops decoding images for a menu that may never have anything in it.

Settings through a source generated context

And read whole and parsed in one go rather than off an async FileStream. Together 31 ms → 21 ms — less than the generator promises, and worth saying plainly:

  • source generation: 4 ms
  • dropping the async machinery: 6 ms
  • what remains: ~20 ms of System.Text.Json being loaded and jitted for the first time, which no amount of generated code avoids for a file under 200 bytes

Kept anyway, since it is strictly cheaper and keeps the reflection based serialiser off the startup path — the part that grows on a cold start.

Deliberately not touched

Logging.Init, 23 ms, still ahead of the icon. It could move after it, but then "Mutex already exists. Exiting." and anything the Main catch reports would have nowhere to go. A tray that failed to start with no explanation costs more than 23 ms is worth.

43 ms in new ContextMenuStrip, plus 15 ms building its items. Already after the icon is up. Deferring them to the first right click only moves the cost to somewhere the user is waiting on it.

The duplicate EnableVisualStyles / SetCompatibleTextRenderingDefault calls go with the reorder: Main already makes both immediately before calling Inner.

Tests

Full solution: 1905 tests, 0 failed, 20 skipped. ImagesTest already touches every image, so the lazy fields stay covered, and SettingsHelperTests.ReadWrite pins that the written JSON is unchanged.

The icon appeared 121ms into a warm start, and most of what came before it was
work nothing about the icon needed. Measured from process start, it is now 81ms.
Time to fully ready is unchanged at around 210ms, which is the honest summary:
this moves the wait rather than removing much of it, and the wait it moves is the
one the user is watching.

Inner reads the settings file after creating the NotifyIcon rather than before.
Nothing between the two needs a setting - AlwaysKill and the hot keys are both
used further down - and until that line there is nothing in the notification
area to look at. Still after the mutex, so a second instance exits without ever
showing an icon. Worth 33ms.

Images are decoded on first use. A static constructor decoded all eleven the
moment anything touched one, and the first thing to touch one is the tray icon,
where only Default is wanted; four more are on the fixed menu items and the last
five are only ever shown when something is pending. Lazy rather than field
initialisers, which would not have helped, since the runtime initialises a type's
static fields together. Thread safe because the icon is set from the scan timer
as well as from the UI thread. Worth 5ms, and it stops decoding images for a menu
that may never have anything in it.

Settings deserialise through a source generated context, and the file is read
whole and parsed in one go rather than off an async FileStream. Together 31ms to
21ms - less than it looks, and less than the generator promises: it is worth 4ms
of that, and the async machinery 6ms, while the remaining 20ms is System.Text.Json
being loaded and jitted for the first time, which no amount of generated code
avoids for a file under 200 bytes. Kept anyway, since it is strictly cheaper and
keeps the reflection based serialiser off the startup path, which is the part
that grows on a cold start.

Deliberately not touched:

Logging.Init is 23ms and still ahead of the icon. It could move after it, but
then "Mutex already exists. Exiting." and anything the Main catch reports would
have nowhere to go, and a tray that failed to start with no explanation costs
more than 23ms is worth.

The 43ms in new ContextMenuStrip, and 15ms building its items, are already after
the icon is up. Deferring them to the first right click only moves the cost to
somewhere the user is waiting on it.

The duplicate EnableVisualStyles and SetCompatibleTextRenderingDefault calls go
with the reorder: Main already makes both immediately before calling Inner.
The only await left in SettingsHelper.Read was a 2 byte first run write, and it kept a state machine and a Task on the path that runs at every startup. Read and GetSettings are now sync, and the ReSharper suppression on the ReadAllBytes call goes with them - that inspection only fires inside an async method.

Write stays async: Swap retries File.Move ten times with a 20ms delay, and blocking the UI thread for 200ms while a backup holds the file is the failure it exists to avoid.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant