Why I Built a Tab Auto-Refresh Extension
I test web applications for a living. Part of that testing involves checking that real-time features — notifications, live dashboards, auto-updating data — actually work. The tool I needed was simple: refresh a tab every 30 seconds and show me a countdown so I know when the next refresh happens.
Every existing auto-refresh extension I tried had problems. Some didn't persist state across browser restarts. Some used Manifest V2, which Firefox is deprecating. Some collected telemetry. Some had UIs that looked like they were designed in 2012.
So I built Auto Refresh Tab — a privacy-first, Manifest V3 extension with per-tab state, a visual countdown ring, and zero data collection. It took about two weeks of evenings and weekends.
Manifest V3: The Mental Shift
Manifest V3 is the biggest change to browser extension development since the original manifest. The most impactful change: background pages are replaced by service workers.
In Manifest V2, your background page was a persistent HTML page that lived as long as the browser was running. You could store state in variables, set up intervals, and assume your code was always running.
In Manifest V3, the service worker can be terminated at any time. Firefox may kill it after 30 seconds of inactivity, or when the browser needs memory. When a new event fires, the service worker restarts from scratch.
This means every piece of state must be persisted to storage, and every timer must be recoverable from storage. You can't just setInterval and hope it survives.
{
"manifest_version": 3,
"name": "Auto Refresh Tab",
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["tabs", "storage", "alarms"],
"action": {
"default_popup": "popup.html",
"default_icon": "icon.svg"
}
}
Note the alarms permission. In Manifest V3, setInterval in a service worker is unreliable — the worker might be terminated before the next tick. The alarms API is the replacement. It survives service worker restarts because alarms are managed by the browser, not by your code.
Per-Tab State with storage.session
Each tab needs its own independent interval, count, and settings. Tab A refreshing every 30 seconds shouldn't affect Tab B refreshing every 5 minutes.
The state model is straightforward:
type TabState = {
tabId: number;
intervalSeconds: number;
maxRefreshes: number;
currentCount: number;
isPaused: boolean;
isRandom: boolean;
startedAt: number;
};
I used storage.session for persistence. Session storage survives extension reloads and browser restarts (unlike the in-memory approach), but is cleared when the browser is fully closed. This is the right behavior for tab refresh state — you don't want stale refresh timers lingering after a restart.
// Save state for a tab
async function saveTabState(state: TabState) {
const key = `tab-${state.tabId}`;
await browser.storage.session.set({ [key]: state });
}
// Load state for a tab
async function loadTabState(tabId: number): Promise<TabState | null> {
const key = `tab-${tabId}`;
const result = await browser.storage.session.get(key);
return result[key] || null;
}
// Load all active tab states (for service worker restart recovery)
async function loadAllTabStates(): Promise<TabState[]> {
const all = await browser.storage.session.get(null);
return Object.entries(all)
.filter(([key]) => key.startsWith("tab-"))
.map(([, value]) => value as TabState);
}
The storage.session API has a 10MB limit, but each tab state is about 200 bytes. Even with 100 active tabs, that's only 20KB — well within the limit.
Service Worker Recovery
This is the part that most Manifest V3 tutorials gloss over. When the service worker restarts, it needs to rebuild all timers from persisted state. Here's my recovery logic:
// background.ts — runs on service worker startup
import browser from "webextension-polyfill";
async function recoverTimers() {
const tabStates = await loadAllTabStates();
for (const state of tabStates) {
// Check if the tab still exists
try {
await browser.tabs.get(state.tabId);
} catch {
// Tab was closed, clean up
await browser.storage.session.remove(`tab-${state.tabId}`);
continue;
}
if (state.isPaused) continue;
// Calculate remaining time
const elapsed = (Date.now() - state.startedAt) / 1000;
const remaining = state.intervalSeconds - (elapsed % state.intervalSeconds);
// Set alarm for the next refresh
await browser.alarms.create(`refresh-${state.tabId}`, {
delayInMinutes: remaining / 60,
});
}
}
// Run recovery on startup
recoverTimers();
// Also run when the alarm fires
browser.alarms.onAlarm.addListener(async (alarm) => {
if (!alarm.name.startsWith("refresh-")) return;
const tabId = parseInt(alarm.name.replace("refresh-", ""));
const state = await loadTabState(tabId);
if (!state || state.isPaused) return;
// Check max refreshes
if (state.maxRefreshes > 0 && state.currentCount >= state.maxRefreshes) {
await clearTabTimer(tabId);
return;
}
// Refresh the tab
await browser.tabs.reload(tabId);
// Update count and schedule next refresh
state.currentCount++;
state.startedAt = Date.now();
await saveTabState(state);
await browser.alarms.create(`refresh-${state.tabId}`, {
delayInMinutes: state.intervalSeconds / 60,
});
});
The key line is state.startedAt = Date.now() after each refresh. This resets the timer anchor so that even if the service worker was terminated mid-interval, the next alarm fires at the correct time.
The SVG Countdown Ring
The popup UI shows a visual countdown ring for each active tab. This is a circle that empties as time passes, giving users an at-a-glance view of when the next refresh happens.
The ring is an SVG <circle> with stroke-dasharray and stroke-dashoffset:
function CountdownRing({
progress,
size = 48,
strokeWidth = 4,
}: {
progress: number; // 0 to 1
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - progress);
return (
<svg width={size} height={size} className="countdown-ring">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
opacity={0.2}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: 'stroke-dashoffset 0.5s linear' }}
/>
</svg>
);
}
The animation uses requestAnimationFrame to update the progress smoothly:
function useCountdownProgress(intervalSeconds: number, startedAt: number) {
const [progress, setProgress] = useState(0);
useEffect(() => {
let animationFrame: number;
function update() {
const elapsed = (Date.now() - startedAt) / 1000;
const remaining = 1 - (elapsed % intervalSeconds) / intervalSeconds;
setProgress(remaining);
animationFrame = requestAnimationFrame(update);
}
animationFrame = requestAnimationFrame(update);
return () => cancelAnimationFrame(animationFrame);
}, [intervalSeconds, startedAt]);
return progress;
}
The ring animates from 1 to 0 over the interval duration. When it reaches 0, the tab refreshes and the ring resets to 1. The transition: 'stroke-dashoffset 0.5s linear' on the SVG element smooths out any jank from the requestAnimationFrame updates.
Tab Lifecycle Handling
Tabs close, navigate, and change state in ways that affect the extension. I had to handle several edge cases:
// Tab closed — clean up state and cancel alarm
browser.tabs.onRemoved.addListener(async (tabId) => {
await clearTabTimer(tabId);
});
// Tab navigated — keep the timer if it's the same domain
browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status !== "complete") return;
const state = await loadTabState(tabId);
if (!state) return;
// If the tab navigated to a new URL, check if we should keep refreshing
// (We keep refreshing — the user set this tab to auto-refresh)
// But reset the timer anchor to avoid refreshing during navigation
state.startedAt = Date.now();
await saveTabState(state);
});
// Extension installed/reloaded — recover all timers
browser.runtime.onInstalled.addListener(() => {
recoverTimers();
});
The trickiest edge case was private browsing. Firefox doesn't persist storage.session data in private windows. I detect private browsing and disable persistence for those tabs:
async function handleTabCreated(tab: browser.Tabs.Tab) {
if (tab.incognito) {
// Store in memory only — won't persist across restarts
inMemoryStates.set(tab.id!, createDefaultState(tab.id!));
return;
}
// Normal tab — use storage.session
await saveTabState(createDefaultState(tab.id!));
}
The Firefox Add-ons Review
Publishing to Firefox Add-ons requires a manual review. The reviewers scrutinize permissions carefully. I requested tabs, storage, and alarms — all necessary for the extension to function. But the initial submission was rejected because I also requested activeTab, which was unnecessary.
The review feedback was specific:
- "Explain why each permission is needed in the notes to reviewer"
- "The
tabspermission exposes URL information — confirm this is necessary" - "No external network requests detected — confirm the extension is fully offline"
I added a notes_to_reviewer field in the manifest:
{
"browser_specific_settings": {
"gecko": {
"notes_to_reviewer": "This extension requires the 'tabs' permission to refresh tabs by ID using browser.tabs.reload(). It requires 'storage' to persist per-tab refresh settings. It requires 'alarms' to schedule refresh intervals that survive service worker restarts. No data is sent to external servers."
}
}
}
The second submission was approved within 24 hours. The key was being transparent about why each permission was needed and confirming the extension makes no external network requests.
Lessons Learned
The biggest lesson: Manifest V3 forces you to build resilient systems. The service worker lifecycle is unforgiving — if you forget to persist state, it's gone. If you forget to recover timers, they stop. This discipline makes the extension more reliable than its Manifest V2 predecessors, even though the development experience is more complex.
The second lesson: per-tab state is surprisingly hard. It sounds simple — just store some numbers per tab ID. But tabs close, navigate, get pinned, get duplicated, and change containers. Each edge case needs handling. I spent more time on tab lifecycle bugs than on the actual refresh logic.
The third lesson: Firefox's review process is reasonable. They're not trying to reject your extension — they're trying to protect users. If your permissions are justified and your notes are clear, you'll pass.
