Loading Popup Data Without a Flash of Empty UI
Render an MV3 popup with real data on the first frame — synchronous-feeling storage reads, skeletons that do not flash, and never waiting on a cold service worker.
Table of Contents
The popup opens, shows an empty panel for 300 milliseconds, then snaps into its real layout. On a surface the user opens twenty times a day, that flash is the single most noticeable quality defect an extension can have — and it is almost always caused by asking the service worker a question instead of reading a value that was already on disk. This guide is part of extension popup architecture.
Where the delay comes from
Three costs stack up between the click and the first useful frame, and only one of them is yours to remove cheaply.
The popup’s own HTML, CSS and JavaScript parse in a few milliseconds — they are local files with no network. The expensive step is whatever the first line of your script awaits.
Step-by-step
1. Read from storage, not from the worker
1// popup.js — first statement, before anything else
2const { summary, settings } = await chrome.storage.session.get(["summary", "settings"]);
3render(summary ?? lastKnownSummary(), settings ?? DEFAULTS);
Execution context: the popup document. chrome.storage is available to the popup directly and does not involve the service worker at all, so a read completes whether the worker is running or evicted.
The consequence is that the worker must keep a render-ready summary in storage. That is a small discipline with a large payoff: every time the worker learns something the popup displays, it writes the display shape rather than the raw data.
1// service worker, after any job that changes what the popup shows
2await chrome.storage.session.set({
3 summary: { unread: items.length, lastSync: Date.now(), syncing: false },
4});
Execution context: the service worker. Writing the rendered shape rather than the source data means the popup does no computation before its first paint.
2. Ship defaults in the document, not in JavaScript
Even a six-millisecond storage read is one frame. For the parts of the UI that never change — the chrome, the labels, the layout — put them in the HTML so they paint before your script runs at all.
1<body>
2 <header><h1>Reader</h1><button id="sync">Sync now</button></header>
3 <ul id="list" aria-busy="true">
4 <li class="skeleton" aria-hidden="true"></li>
5 <li class="skeleton" aria-hidden="true"></li>
6 </ul>
7</body>
Execution context: the popup document, parsed before any script executes. aria-busy tells assistive technology the region is loading without needing a live region announcement — the pattern in announcing dynamic updates to screen readers.
3. Make the skeleton not flash
A skeleton that appears and disappears within 80 milliseconds reads as a glitch, not as loading. Either render real data immediately, or delay the skeleton until it is warranted.
1.skeleton { animation: ext-fade-in 120ms 100ms both; }
2@keyframes ext-fade-in { from { opacity: 0 } to { opacity: 1 } }
3@media (prefers-reduced-motion: reduce) { .skeleton { animation: none } }
Execution context: the popup’s stylesheet. The 100 ms delay means a fast load never shows the skeleton at all, and a slow one shows it smoothly. The reduced-motion query is not optional — an animation the user has asked not to see is an accessibility defect.
4. Upgrade in the background
Render the cached value first, then refresh. The user sees content immediately and the correction, when it comes, is a small change rather than a layout appearing.
1render(cached); // frame 1
2
3chrome.runtime.sendMessage({ type: "summary:refresh" })
4 .then((fresh) => { if (fresh) render(fresh); }) // frame N, maybe
5 .catch(() => {}); // worker asleep — cached stands
Execution context: the popup. The catch matters: a cold worker may not answer before the popup closes, and that must not surface as an error. The retry discipline is in messages sent while the worker is starting.
5. Reserve the space the content will occupy
Most perceived flashing is layout shift, not blankness. If the list will be 320 pixels tall, make the skeleton 320 pixels tall.
1#list { min-height: 320px; }
2.skeleton { height: 44px; } /* the exact height of a real row */
Execution context: the popup’s stylesheet. Matching the skeleton’s geometry to the real row is what turns a jarring reflow into a content swap the eye barely registers.
6. Avoid the accidental round trip
Three things quietly reintroduce the cold-worker wait:
- A shared module that messages the worker on import. The import runs before your render call, so it moves the wait earlier rather than removing it.
chrome.tabs.queryin the first line. It is a browser-process call, not a worker call, so it is fast — but awaiting it before the first render still costs a frame. Render first, then query.- An analytics ping at startup. Fire it after the first paint, from a
requestIdleCallback.
1render(cached);
2requestIdleCallback(() => {
3 chrome.runtime.sendMessage({ type: "metric:popupOpen" }).catch(() => {});
4});
Execution context: the popup document. requestIdleCallback is available in Chrome and Firefox; on Safari fall back to a short setTimeout, which is safe here because the popup owns its own event loop.
Keeping the cached summary fresh enough
Rendering from a cached summary is only honest if the cache is usually right. Three writers keep it that way, and the discipline is to make every one of them cheap enough that nobody is tempted to skip it.
Write on change, not on a schedule. Anywhere the worker already knows something the popup displays — a sync finished, a rule matched, a count changed — write the summary as part of that work.
1export async function publishSummary(patch) {
2 const { summary = {} } = await chrome.storage.session.get("summary");
3 await chrome.storage.session.set({ summary: { ...summary, ...patch, at: Date.now() } });
4}
Execution context: the service worker. A merge rather than a replace means each call site only has to know its own field, which is what makes adding a new writer a one-line change.
Write on tab activation for per-tab facts. A summary that includes “blocked on this site” must be recomputed when the active tab changes, because the popup will open against whichever tab is in front.
1chrome.tabs.onActivated.addListener(async ({ tabId }) => {
2 const tab = await chrome.tabs.get(tabId);
3 await publishSummary({ enabledHere: isEnabled(tab.url), blocked: countFor(tabId) });
4});
Execution context: the service worker, at the top level. This wakes the worker on every tab switch, which is a real cost — if the summary is cheap to compute in the popup from data already in storage, prefer doing it there instead.
Stamp it, and show your working. Recording at lets the popup decide whether the cached value is worth showing without qualification. A summary from four hours ago should render with a quiet “as of 11:20” rather than as current fact.
1const age = Date.now() - (summary.at ?? 0);
2if (age > 15 * 60_000) meta.textContent = `as of ${new Date(summary.at).toLocaleTimeString()}`;
Execution context: the popup. This is the small honesty that makes a cache-first UI trustworthy — the user is never shown a stale number as if it were live.
Cross-browser variation
- Chrome / Edge: the popup document is created fresh on every open and its scripts are parsed each time, so a large popup bundle costs on every click.
chrome.storage.sessionreads are fast and do not wake the worker. - Firefox: the same model. Firefox’s background event page is more often already running, which makes the cold-worker case rarer but not absent — do not let that tempt you into messaging first.
- Safari: popup creation is slower and the background context is more often asleep, so the storage-first pattern matters most here.
requestIdleCallbackis unavailable; use a short timeout. - All three: the popup has no service worker of its own and cannot be kept warm between opens. Every open is a cold document.
Verification
- Measure the first paint directly:
1performance.mark("ext:script");
2render(cached);
3performance.mark("ext:painted");
4performance.measure("ext:ttfp", "ext:script", "ext:painted");
5console.debug(performance.getEntriesByName("ext:ttfp")[0].duration);
6// 3.4
Execution context: the popup’s DevTools console, opened with right-click → Inspect popup. Anything over about 50 ms means something is being awaited before render.
- Stop the service worker from
chrome://extensions, then open the popup. It must render fully — a blank or skeleton-only popup here means the render path depends on the worker. - Record a DevTools Performance trace of an open and confirm there is no layout shift after the first frame.
- Open and close the popup ten times quickly and confirm no skeleton is ever visible.
FAQ
Is storage.session or storage.local better for the cached summary?
storage.session for anything derived that should be recomputed after a browser restart; storage.local for a value that is still meaningful tomorrow. Reading both in one get call costs no more than reading one.
Should I render before or after awaiting storage?
After — the read is fast enough that a pre-read frame would show nothing useful. What matters is that the awaited thing is storage rather than a message.
How do I show an error if the refresh fails?
Quietly, and not on the first frame. A small inline note under the content is right; replacing the cached view with an error state throws away information the user can still use.
Related
- Why the popup closes and how to work with it — the lifetime this render strategy is built around.
- Popup loading and empty states — the visual design of the same problem.
- Reducing service worker cold start latency — shrinking the cost when a round trip is unavoidable.
- Extension popup architecture — the parent guide.