Speeding Up Popup First Paint
Make an extension popup appear instantly: measuring time to first paint, a minimal critical path, rendering a cached snapshot before data loads, deferring heavy modules, avoiding storage waterfalls, and reducing layout shifts as the popup sizes.
Table of Contents
Users click the toolbar icon dozens of times a day, and every time the popup shows a blank white box for 300 milliseconds, then a spinner, then content that jumps as the popup resizes. Each step is small; together they make the extension feel sluggish. The popup is recreated from scratch on every open — no cached DOM, no warm JavaScript — so its startup path runs constantly. A fast popup paints meaningful content within one or two frames: a small HTML shell, styles inline-sized for the first view, a snapshot of the last-known state rendered synchronously, and everything else loaded after. This guide measures and fixes popup startup. It belongs to performance profiling and optimisation.
What happens when the popup opens
On click, the browser creates a new document for popup.html, parses the HTML, loads and parses CSS and JavaScript, runs scripts, and paints. The bubble’s size is determined from the rendered content, so it can grow or shrink as content arrives. Common delays are large bundles (a framework plus libraries parsed on every open), sequential awaits on chrome.storage and chrome.tabs before anything renders, web fonts, and messages to a service worker that must first start up. The goal is a first paint that already looks like the final UI, with data that is correct or nearly so, and no size jump when the rest arrives.
Step-by-step: a fast popup
1. Measure first
1// popup.js — development measurement
2const t0 = performance.timeOrigin;
3new PerformanceObserver((list) => {
4 for (const e of list.getEntries()) console.log(`[popup] ${e.name}: ${Math.round(e.startTime)} ms`);
5}).observe({ type: "paint", buffered: true });
6performance.mark("app-rendered"); // call after your first meaningful render
Execution context: the popup, opened with Inspect popup so DevTools is attached. first-paint and first-contentful-paint entries show when the browser first drew; a custom app-rendered mark shows when your real content appeared. Record these before and after each change; the Performance panel’s recording of a popup reload (Ctrl+R in the popup’s DevTools) shows exactly where the time goes. See debugging a popup before it closes.
2. Keep the critical path tiny
1<!-- popup.html -->
2<!doctype html>
3<html>
4<head>
5 <meta charset="utf-8">
6 <link rel="stylesheet" href="popup.css"> <!-- small, first-view styles only -->
7 <script type="module" src="popup.js"></script> <!-- small entry; heavy parts loaded later -->
8</head>
9<body>
10 <header class="bar"><input id="q" type="search" autofocus aria-label="Search"></header>
11 <ul id="list" aria-busy="true"></ul> <!-- skeleton height reserved by CSS -->
12</body>
13</html>
Execution context: the popup’s HTML. Static markup for the first view paints before any JavaScript runs. Inline scripts are blocked by the MV3 CSP, so keep the entry module small instead. Use system fonts in the popup — web fonts delay text paint or cause a font swap. Reserve the list’s height in CSS so the bubble opens at its final size.
3. Render a cached snapshot before fresh data
1// popup.js
2const { popupSnapshot } = await chrome.storage.session.get("popupSnapshot");
3if (popupSnapshot) renderList(popupSnapshot.items, { stale: true });
4
5const [fresh, [tab]] = await Promise.all([
6 chrome.storage.local.get(["items", "settings"]),
7 chrome.tabs.query({ active: true, currentWindow: true }),
8]);
9renderList(selectVisible(fresh.items, tab), { stale: false });
10document.querySelector("#list").removeAttribute("aria-busy");
11
12addEventListener("pagehide", () => {
13 chrome.storage.session.set({ popupSnapshot: { items: currentVisibleItems().slice(0, 30), at: Date.now() } });
14});
Execution context: the popup. A small snapshot of exactly what the popup showed last time — the first 30 rows, already filtered — renders in one storage read, usually within a frame or two. Fresh data then patches it in place. storage.session is in memory and fast; keep the snapshot small. Mark stale rows subtly if correctness matters. See preserving popup state when it closes.
4. Read in parallel, not in a waterfall
1// Slow: three sequential round trips before render
2const s = await chrome.storage.sync.get("settings");
3const l = await chrome.storage.local.get("items");
4const [t] = await chrome.tabs.query({ active: true, currentWindow: true });
5
6// Fast: one round of parallel calls
7const [{ settings }, { items }, [tab]] = await Promise.all([
8 chrome.storage.sync.get("settings"), chrome.storage.local.get("items"), chrome.tabs.query({ active: true, currentWindow: true }),
9]);
Execution context: the popup. Each extension API call crosses a process boundary; sequential awaits add their latencies together. Promise.all overlaps them. Also batch keys into one get per area. See measuring storage read and write latency.
5. Avoid the service worker on the critical path
If the popup sends a message to the worker and waits for the answer before rendering, every open pays for a possible worker cold start. Read what the popup needs directly from chrome.storage (the popup is a trusted context with full storage access) and use messages only for actions. See reducing service worker cold-start latency.
6. Lazy-load heavy features
1document.querySelector("#export").addEventListener("click", async () => {
2 const { exportLibrary } = await import("./features/export.js"); // separate chunk
3 exportLibrary();
4});
Execution context: the popup. Dynamic import() of packaged modules is allowed in MV3 (it loads files from the extension, not remote code). Features used occasionally — export, editors, charts, settings panels — should not be parsed on every open. See code-splitting and dynamic imports in MV3.
7. Render only what is visible
Render the first screenful of rows immediately and the rest after the first paint (or virtualise long lists). Layout cost scales with DOM size, and a popup only shows a few hundred pixels. See rendering long lists in a popup.
Common mistakes
- Spinner-first popups. Paint a snapshot instead.
- Awaiting the worker before render. Cold starts add latency.
- Sequential storage reads. Parallelise and batch.
- One big bundle. Split off rarely used features.
- No reserved size. The bubble jumps as content loads.
Cross-browser variation
- Chrome / Edge: popups are fresh documents each open;
storage.sessionis ideal for snapshots. - Firefox: similar lifecycle;
storage.sessionin recent versions, otherwise snapshot instorage.local. - Safari: popovers can be slower to first appear; the same minimal-shell approach helps most.
Verification
- Record a popup reload in the Performance panel and confirm first meaningful paint under ~100 ms on a typical machine.
- Confirm the popup opens at its final size without jumping.
- Confirm heavy feature chunks are not loaded until used (Network panel).
- Stop the worker and confirm the popup still paints immediately.
FAQ
Should the popup prefetch data in the worker?
Keep the snapshot fresh from the worker when data changes, so the popup only reads it.
Does framework choice matter?
Bundle size matters more than the framework; small frameworks or web components help. See using web components in extension popups.
Is localStorage faster than storage.session?
It is synchronous, which can help for a tiny snapshot, but it blocks parsing and is per page. storage.session is usually fast enough.
How fast is fast enough?
Aim for meaningful content in the first frame or two after the bubble appears — under about 100 ms on a mid-range laptop — and no visible layout jump.
Should I keep the snapshot fresh from the service worker?
Yes, when data changes in the background — after a sync, for example — update the snapshot in storage.session at the same time, so the next popup open paints current data immediately rather than last session’s view.
Does a skeleton screen help?
Only when there is no snapshot, such as the very first open. A skeleton with the same dimensions as real rows avoids layout shifts while data loads.
Related
- Keeping the extension bundle small — fewer bytes to parse.
- Popup loading and empty states — when there is no snapshot.
- Fixing popup size and overflow issues — stable sizing.
- Performance profiling and optimisation — the parent topic.