Skip to content

Building a cache-first dashboard — explicit fetch and a closes-but-keeps-running notice

When we shipped a cross-site dashboard in v1.6.2 — a single view that shows plugin-update status across multiple WordPress sites — we hit a UX wall almost immediately. Opening the dashboard meant waiting 24.5 seconds, every time. And the wait got longer as more sites were added.

A user put it bluntly: “It launches a heavy operation the moment I open it. I wasn’t mentally prepared for that.” This post walks through the slightly unusual async-UX combination we landed on for that page — cache-first display, explicit fetch, and a “closes-but-keeps-running” notice.

What was eating the time

Under the hood, the dashboard was running parallel SSH scans against every connected site, invoking wp plugin list --update=available and aggregating which plugins needed updates. Reasonable behavior for a WordPress maintenance tool, but doing it on every open meant a multi-second silence every time.

Note: SSH (Secure Shell — the protocol used to log into servers and run commands securely) is being used here to call WP-CLI (the official WordPress command-line interface) on each site. We were already parallelizing, but the sum of network latency and per-site response time still landed as a noticeable load wait.

The turning point was asking, “Does this actually need to be real-time?” Most of the time, users aren’t looking for this very moment’s accurate update status — they want a rough overview of recent days. If that’s the framing, the dashboard can be a planning / inventory tool, not a real-time monitor. That reframe became the design’s foundation.

Element 1 — Cache-first display (localStorage + 7-day TTL)

Each site’s scan result is now stored in the browser’s localStorage, and the next time the dashboard opens, that cached data renders immediately. The TTL (time-to-live — how long the cache stays valid) started at 24 hours but ended up at 7 days.

const _DASH_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

// Save
localStorage.setItem(_DASH_CACHE_KEY, JSON.stringify(payload));

// Restore
const raw = localStorage.getItem(_DASH_CACHE_KEY);
if (raw) {
    const cached = JSON.parse(raw);
    if (Date.now() - cached.timestamp < _DASH_CACHE_MAX_AGE_MS) {
        renderDashboard(cached.data); // 0-second display
    }
}

A 7-day TTL would normally feel aggressive, but there’s a separate hook that invalidates the cache whenever a maintenance run completes successfully. Sites that were actually updated get refreshed on the next maintenance pass, so the mental model is “sites you maintain stay fresh; sites you don’t can stay up to 7 days stale” — which lines up with how teams actually use this view.

Element 2 — Explicit fetch (no more auto-load)

The cache-hit case is now a non-issue: zero-second display. The harder question is what to show when there’s no cache (first open, expired, or just manually cleared).

If we auto-start the fetch, we’re back to “a heavy operation runs the moment you open the dashboard.” So instead, we made the fetch explicitly user-initiated. When the cache is empty, the center of the dashboard shows this prompt:

            📊
Fetch latest plugin-update info?
(This runs parallel SSH scans across multiple sites — may take tens of seconds.)

       [ 📊 Start fetch ]

The parallel scan only starts when the button is clicked. The user knows a heavy operation is about to start before it starts. The same 24.5 seconds feels completely different when you’re mentally prepared for it vs. when you weren’t.

Element 3 — A “closes-but-keeps-running” notice

The third piece is small but pulls a lot of weight. While the scan is in progress, users might want to close the modal — switch tasks, look at another screen. So we built in “closing the dashboard doesn’t stop the scan”, and surface that with a notice at the bottom of the modal while scanning:

Closing this window doesn’t stop the scan. Open it again after it finishes to see the results.

The implementation is almost trivial: start the fetch as a Promise, write to localStorage on resolve, and let the modal’s open/close state live entirely separately. Close it, do something else for 30 seconds, reopen — the cache-first path now serves the result in zero seconds.

This notice line started life as two lines with a 💡 icon. After ship, we dropped the icon and let it collapse to a single line. Small text tweaks like this matter more than you’d expect for how the dashboard feels.

Keep the invalidation hook

The risk of cache-first display is showing stale data forever. That’s what the maintenance-completion hook compensates for:

// After a maintenance run
if (maintenance_result.success) {
    localStorage.removeItem(_DASH_CACHE_KEY);
}

Sites that just got maintained have definitively changed state, so the next dashboard open hits the explicit-fetch prompt instead of the stale cache. The principle becomes: “stale is fine when nothing has changed; explicit refresh is required when state has changed.”

Closing — the three elements of async UX

If you abstract out the design, three principles seem reusable for any view that wraps a long-running operation.

  1. Always have an immediate-display path. Even stale cached data beats silence. Just rendering something breaks the empty-screen wait.
  2. Heavy operations need explicit consent. Tell the user something heavy is starting, then start it. Same elapsed time, very different experience.
  3. Closing shouldn’t cancel; reopening should resume. Don’t trap users on the loading screen. Let them close, come back later, and find the result waiting.

What started as a “real-time monitor” landed as a “planning tool” once we reframed the dashboard’s purpose — and once we did, all three elements snapped into place. For any UI dealing with multi-record or multi-site aggregation, having these three async-UX moves in your toolkit can be the difference between a 24.5-second silence and a dashboard people actually open.