Why Opening a Dropdown Costs You Three Seconds


You hit Ctrl+P to switch models. The dropdown opens — empty — and the cursor spins for three or four seconds before the list fills in. You blame the network, but at the same moment hermes plugins list is also sitting there doing nothing, and the Profiles panel in the desktop sidebar drops a frame every time it refreshes. Three surfaces, three stalls, and if you read their implementations you find the same habit: the path that reads a list was also reaching for the network and walking the filesystem.

Three PRs merged on 18 September 2026 untangle that habit — #114994 for the model picker, #114811 for plugin listings, #114879 for the Profiles panel. They are worth reading together because the principle behind all three is one sentence: the read path touches only local data; the network and the heavy work move to the background.

Three stalls, one cause

Surface What every read used to do What it really cost
Model picker (Desktop / TUI / dashboard) call /v1/models and auth probes on every authed provider one request per provider; the slowest one sets your wait
hermes plugins list / Plugins page fetch the live catalog for every plugin row (and every candidate name of it) plugins × candidates × timeout; when the catalog host is unreachable, the stall multiplies
Profiles panel / GET /api/profiles rglob("SKILL.md") per profile to count skills a full skill-tree walk on every poll

The third one is the archetype: it does not stall once, it stalls every poll. The desktop app pulls the profile list every few seconds, so “count the skills” — something that needs to happen once a day — became continuous I/O pressure.

The model picker: serve the cache, refresh behind it

The model.options read path (refresh=False) now behaves as stale-while-revalidate: it returns cached-or-curated rows immediately and refreshes stale catalogs off-thread.

Mechanically, a non_blocking_catalogs flag travels down the call chain into cached_provider_model_ids(non_blocking=True):

  • a same-credentials row in the disk cache is served at any age, with the refresh spawned off-thread;
  • a cold row returns an empty list, so the caller keeps its curated list — you never get a blank dropdown;
  • the parallel prefetch pool (which the caller used to join with no timeout) is skipped entirely on this path.

Several smaller fixes in the same PR show where the seconds actually came from:

  • Concurrent writers needed a lock. Threads refreshing different providers were dropping each other’s rows; writes now serialize under _cache_write_lock.
  • Ollama Cloud’s 8-second probe. The read path passes cache_only=, skipping that probe — and it no longer writes the disk cache at all (only a result that included the live catalog is persisted).
  • Codex and Copilot fall back to their curated list on a cold read (the contributor’s initial version rendered an empty Copilot row).
  • Z.AI failures are remembered. A key that 429s on every endpoint previously re-ran four chat-completion probes on each picker open, which is where the reporter’s repeated POST …/chat/completions -> 429 lines came from. Failed endpoint detection is now remembered in-process for five minutes, matching the negative cache Copilot’s token exchange already had.
  • refresh=True is unchanged. The “Refresh models” button still busts the cache and probes every provider live. The instant answer is what you get by default; the live answer is a deliberate click.

Plugin listings: remember failures, hoist the repeated work

Two problems sat behind hermes plugins list:

  1. A failed live-catalog fetch was never remembered. fetch_live_catalog() wrote its cache only on success and left nothing behind on failure, so an unreachable catalog host meant waiting out a fresh connection timeout on every call.
  2. The kill list was computed at the wrong granularity. Every plugin row — and each of its up-to-three candidates (the name, the sidecar’s catalog name, the sidecar’s repo) — independently walked “look up the removed list” → “fetch the live catalog”. The total was plugins × candidates × timeout.

The fix does two things: fetch_live_catalog now remembers a failed attempt for LIVE_CATALOG_FAILURE_TTL_SECONDS (60 seconds), during which a stale on-disk copy still answers and the network is left alone (force=True bypasses both caches); and the kill list is resolved once per listing and once per hub rebuild (resolved_removed_entries()), with each row matched against that single result (removed_annotation(...)).

One less visible fix has the loudest felt effect: the dashboard’s GET /api/dashboard/plugins/hub now awaits asyncio.to_thread(_merged_plugins_hub). The rebuild used to run on the event loop, which meant HTTP requests and every WebSocket froze while it ran — you saw the whole interface hang, not just a slow plugins page.

The Profiles panel: polling should not walk skill trees

The third fix is the cleanest: skill counts go from computed-on-read to last-known value plus a background recount.

  • list_profiles(lazy_skill_count=True) returns the last known skill_count; a never-counted or aged entry schedules one background _count_skills per profile per 60-second recheck window, so the request thread does zero skill-tree I/O and the refresh cadence is decoupled from the poll rate.
  • That is the mode used by GET /api/profiles, the profiles.list RPC and the REST fallback entry.
  • The synchronous default is unchanged: hermes profile list/show and profiles.describe still report fresh numbers. The accurate path was not sacrificed — only the polled one.
  • Counting itself got cheaper: _walk_skill_count now uses the repository’s own walker (agent.skill_utils.iter_skill_index_files, an os.walk with pruning) instead of Path.rglob — roughly half the filesystem calls, and best-effort when a subtree vanishes mid-walk instead of aborting the whole enumeration.
  • The skill-count TTL moved from 30 seconds to 600, with signature invalidation still catching skill add/remove on the next refresh.
  • Two more routes (/api/profiles/projects/tree and /api/profiles/sessions/pull-requests) only ever consumed name and path but paid the full list_profiles() walk; they now use profiles_to_serve, a pure directory read.

Two things to keep in mind

  1. refresh=True is supposed to break the cache. If a model you know exists is missing from the list, clicking “Refresh models” is the correct move — not suspecting a broken cache.
  2. The Desktop and dashboard Profiles panels now show the last known skill count. Right after installing a skill, the number may lag one refresh cycle; that is design, not a bug. For a live number, use hermes profile show <name>.

For context on read-path work, see the command palette post, which covers the same “type and filter instantly” interaction. Catalog review and installation are explained in the plugin catalog guide and the hermes plugins reference; for multi-profile setups see the profiles multi-instance guide and the hermes profile reference.

Release status

All three PRs merged into main on 18 September 2026, and the newest tag is v0.21.3 (tag name v2026.9.14, released 14 September), so none of this is in a tagged release yet:

hermes --version
hermes update

The lesson

Three stalls that look unrelated, one principle: a read path has to stay instant when the network is down and the directory is huge. The reusable rules are three — serve cache first and refresh behind it (serializing concurrent writers), treat a failure as state worth remembering, and hoist repeated work to the right granularity instead of redoing it per row or per profile. Next time some panel “takes a few seconds to open”, ask first: on this read path, who is quietly touching the network or walking the filesystem?