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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

Illustrative: popup time to meaningful paint by techniqueIllustrative comparison of time to meaningful paint for a popup with a large framework bundle and sequential awaits, after code splitting, after parallel reads, and after rendering a cached snapshot synchronously.Baseline (big bundle, awaits)420 ms+ code splitting240 ms+ parallel reads160 ms+ cached snapshot45 ms
Illustrative figures: the snapshot render removes the wait entirely.

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.

Snapshot-first renderingOn open, the popup reads a small cached snapshot from storage.session in one call and renders it synchronously after parse; it then loads fresh data from storage and the worker in parallel, patches the list, and lazily imports heavy features when used; on close it saves a new snapshot.Parse HTML/CSSstatic shellget(snapshot)one readRender snapshotfirst meaningful paintthen, in parallelFresh datastorage + workerPatch listno size jumpLazy importsexport, editor…
Paint the last known state now; reconcile with fresh data next.

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.

Popup startup costs and fixesCommon popup startup costs — large framework bundle, sequential API awaits, worker round trip on open, web fonts, size changes as content loads, large lists — and the fix for each.CauseCostFixLarge bundleParse/compile each openSplit; lazy-load featuresSequential awaitsLatencies add upPromise.all, batch keysWorker message on openWorker cold startRead storage directlyWeb fontsBlank or swapping textSystem font stackResizing bubbleContent jumpsReserve size in CSSHundreds of rowsLayout timeRender first 30; virtualise
Most of the gain comes from doing less before the first paint.

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.session is ideal for snapshots.
  • Firefox: similar lifecycle; storage.session in recent versions, otherwise snapshot in storage.local.
  • Safari: popovers can be slower to first appear; the same minimal-shell approach helps most.

Verification

  1. Record a popup reload in the Performance panel and confirm first meaningful paint under ~100 ms on a typical machine.
  2. Confirm the popup opens at its final size without jumping.
  3. Confirm heavy feature chunks are not loaded until used (Network panel).
  4. 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.

Other Testing, Debugging & Performance Optimization Resources