Skip to main content
All posts
7 min readEngineering

Why hidden WebView2 windows still use CPU, and how we fixed it

Clypy idled at 16.6% of a CPU core on Windows with every window hidden. Why WebView2 kept drawing windows nobody could see, and how we got it to 0.4%.

By The Clypy team

Clypy's desktop app spends most of its life in the tray. On a Windows PC, with every Clypy window hidden and nothing happening, it should cost next to nothing. In September we measured it at 16.6% of one CPU core, around the clock.

After the fix it idles at 0.4%. The cause was a pulsing dot in a window nobody could see. This post is for anyone shipping on WebView2, Tauri, Electron or another Chromium embed: why a hidden window keeps drawing, what we changed, how we measured, and a memory problem of the same shape.

Three windows that never close

The desktop app has three windows: the library, the Paste Strip, and a small heads-up panel (the HUD) that confirms a capture or shows a notice. All three are created at launch with visible: false, and none is ever destroyed. Closing the library only hides it.

That is on purpose. The Paste Strip comes up on a keyboard shortcut over whatever app you are typing in, and it has to be on screen at once. A webview that first has to start, load and render can't do that, so the strip's page is always loaded and waiting. The library's page also runs capture and sync, so it has to stay loaded whether anyone is looking at it or not.

So for most of the app's life, every window is loaded and none is visible. On a Mac that costs nothing. On Windows it did.

Hidden is not the same as not drawing

On macOS, an offscreen window stops getting display refreshes by itself: AppKit stops its display link. A looping CSS animation in a hidden window simply doesn't run. We had profiled the Mac app carefully, and this never showed up.

On Windows, the webview is WebView2, which is Chromium. Chromium keeps producing frames for as long as something on the page animates and nobody has told it the view is hidden. Hiding the native window doesn't tell it. Our strip and HUD are raised and hidden with raw SetWindowPos calls and SWP_NOACTIVATE, so they never take focus from the app you are in, and Tauri's windowing layer never sees those calls. The library is hidden through Tauri's own hide(), and it kept drawing too.

The animation doing the damage was a 6-pixel dot. The library's status bar had a sync indicator that pulsed while the state was "synced". Synced is the steady state, so the window sat in it, hidden, for hours, and Chromium composited it at display rate the whole time. In one development run, the hidden windows cost 21% of a core, and about half of that was in WebView2's shared GPU process, not in our page at all.

Telling the page

WebView2 does have a switch for this. ICoreWebView2Controller::put_IsVisible(false) stops the rendering pipeline outright. We chose not to use it: a webview that hasn't rendered has no frame ready to show, and the strip has to appear the moment its shortcut is pressed.

Instead, each page is told whether it is on screen, and stops its own animations. Only the Rust side knows: Tauri has no visibility event, and nothing else saw the Win32 calls. So every show and hide path in Rust calls one function:

fn set_window_presented(app: &AppHandle, label: &str, presented: bool) {
    // Only on a change: a drag-resize delivers dozens of Resized events a second.
    static LAST: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
    {
        let mut last = LAST.get_or_init(Default::default).lock().unwrap();
        if last.get(label) == Some(&presented) {
            return;
        }
        last.insert(label.to_string(), presented);
    }
    let _ = app.emit_to(label, "clypy://presented", presented);
}

Tauri has no minimize event either, but a minimize is a resize, so the library's Resized handler checks is_visible() and is_minimized() and reports the answer.

In the page, a small module mirrors that onto the document as data-presented on <html>. It starts at false, because every window is created hidden. It doesn't rely on the event alone: an event emitted before the page's listener is attached is simply dropped. So the page also asks Rust directly once its listener is in, and again whenever focus, a pointer or a key reaches a page that believes it is hidden. We added those later questions after a missed show left the library convinced it was hidden while it was on screen.

The CSS half is short:

html[data-presented="false"] *,
html[data-presented="false"] *::before,
html[data-presented="false"] *::after {
  animation-duration: 0.001ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important;
}

animation-play-state: paused looks like the gentler choice, and it broke our entrance animations. They use animation-fill-mode: both, and pausing one that hasn't started holds the element on its first keyframe. The first keyframe of a new clip card's entrance is opacity: 0, so every card rendered while the window was hidden stayed invisible. Collapsing the duration instead lets finite animations finish at once, and an iteration count of 1 stops the infinite ones.

The dot itself now pulses only while a sync is actually running.

How we measured

The CPU numbers come from a release build on one Windows PC, measured on September 22 across the whole process tree: clypy.exe plus the WebView2 processes it starts (a browser process, a GPU process and the renderers), nine processes in all, with every window hidden and the app idle.

Idle, every window hiddenCPU, share of one core
Before16.6%
After0.4%

With the library open and idle, it is also 0.4%.

The whole tree matters. About half the cost was in the GPU process, so a profile of our own page, or of clypy.exe alone, would have shown very little.

Memory needed the same care. Adding up each process's working set gave about 670 MB, but that counts the DLL pages the processes share once for every process. The private working set was about 412 MB. To see how much of that the app actually touches, we trimmed the working sets with the Win32 EmptyWorkingSet call and watched what came back: about 31 MB, and it stayed there for minutes. Roughly 250 MB of the total is WebView2 itself (the browser, GPU and three renderer processes), and that is the price of a strip that opens instantly.

A model that never let go

The same pass found the memory version of this bug. The embedding model behind search by meaning, and the ocrs text recognition model, lived in Rust OnceLocks. A OnceLock can never be emptied, so one search, or the backfill that runs on the first launch after the model is downloaded, loaded them into the tray process for the rest of the session. clypy.exe held about 100 MB of commit instead of 44 MB.

Each model now sits in a slot that can be emptied, stamped with when it was last used:

struct Held<T> {
    value: T,
    last_used: Instant,
}

const MODEL_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
static EMBEDDER: Mutex<Option<Held<MiniLmEmbedder>>> = Mutex::new(None);

A reaper thread wakes every 30 seconds and drops any model that has gone five minutes without work. Reloading the embedding model puts a few hundred milliseconds in front of a search, so five minutes covers a session of searching and still hands the memory back on a machine left alone. With the reaper, clypy.exe drops back to 44 MB.

Since then, the hidden library has learned to let go as well. Thirty seconds after it is hidden, its grid unmounts and its image URLs are revoked, and on Windows it asks WebView2 to aim low on memory (SetMemoryUsageTargetLevel), going back to normal when it is shown again.

The rule we keep

Anything that animates forever has to be checked on Windows, not just on a Mac. In practice:

  • Tie a looping animation to work, not to a steady state. A spinner while something loads ends. A pulse that means "all is well" never does.
  • The CSS rule only covers CSS. A requestAnimationFrame loop or a timer that repaints has to listen for the same signal itself. In Clypy that is onPresenceChange, which the library also uses to unmount its grid.
  • Measure the whole process tree, with every window hidden. If the GPU process is busy while nothing is on screen, something is still being drawn.

On Linux, Tauri uses WebKitGTK rather than Chromium. The same signal and CSS apply there, and Linux starts only the library and the strip at launch: a native GTK helper shows the HUD, and its webview is created only if the helper isn't available.

What isn't done

  • The library's list is unbounded. The library window keeps every clip record it has paged in until it reloads. Scroll through a large library and all of those records stay in memory. Capping the list needs placeholder records, so the virtualized grid's positions stay stable when old pages are dropped.
  • The WebView2 floor stays. About 250 MB of resident webviews is a deliberate trade for a strip that opens instantly, and there is no setting to trade it the other way.
  • It's a convention, not a guarantee. Nothing stops a future change from adding an infinite animation, or a repainting timer the CSS can't reach. Remembering to check is on us.
  • One machine. These figures come from one Windows PC in September, before the library learned to unmount its grid. Yours will differ with the GPU, the display and the size of your library.

Clypy for Windows, Mac and Linux is on the download page. For more on the models mentioned above and what they do on your device, read search by meaning, without sending your clipboard anywhere.