AI News Hub Logo

AI News Hub

I Built a Chrome Extension That Remembers Where You Stopped Reading

DEV Community
SHOTA

Long articles are everywhere — documentation, research papers, Substack posts, long-form journalism. And browser tabs are how we "save" them. We pin them. We bookmark them. We email ourselves links. Then we come back hours later, scroll frantically trying to remember where we left off, give up, and close the tab. I got tired of this workflow and built ReadMark, a Chrome extension that automatically saves your scroll position on any page and restores it when you return. This article covers the technical challenges of building a reliable reading position tracker. The naive solution is to store window.scrollY per URL. But this immediately runs into problems: Infinite scroll pages shift content as you scroll, so the same scrollY value points to different content after more items load. SPAs change the URL without reloading, so you get position restoration at the wrong logical scroll depth. Dynamic content (ads, lazy-loaded images) causes layout shifts that make absolute pixel positions unreliable. The more reliable approach is document fraction position: function getScrollFraction(): number { const scrollTop = window.scrollY || document.documentElement.scrollTop; const scrollHeight = document.documentElement.scrollHeight; const clientHeight = document.documentElement.clientHeight; const scrollable = scrollHeight - clientHeight; if (scrollable { await new Promise(resolve => { if (document.readyState === 'complete') { setTimeout(resolve, 200); } else { window.addEventListener('load', () => setTimeout(resolve, 200), { once: true }); } }); const pendingImages = Array.from(document.querySelectorAll('img')) .filter(img => !img.complete); if (pendingImages.length > 0) { await Promise.race([ Promise.all(pendingImages.map(img => new Promise(resolve => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }) )), new Promise(resolve => setTimeout(resolve, 2000)) ]); } await new Promise(resolve => requestAnimationFrame(resolve)); } The 200ms delay after load catches most synchronous layout shifts. The image wait covers hero images that push content down. The final requestAnimationFrame ensures we are after the browser's next paint cycle. For React/Vue/Next.js sites, history changes are invisible to standard event listeners. I intercept history.pushState and history.replaceState: function monkeyPatchHistory(): void { const originalPushState = history.pushState.bind(history); history.pushState = function(...args) { const result = originalPushState(...args); window.dispatchEvent(new Event('readmark:navigation')); return result; }; window.addEventListener('popstate', () => window.dispatchEvent(new Event('readmark:navigation')) ); } Key insight: save before navigating, not just on scroll. If the user clicks a link before the debounce fires, you lose their position. Scroll events fire at ~60Hz. Writing to chrome.storage.local on every event would saturate the API. I use a 1500ms debounce with a synchronous flush on beforeunload: class PositionTracker { private pendingPosition: number | null = null; private saveTimer: ReturnType | null = null; onScroll(): void { this.pendingPosition = getScrollFraction(); if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(() => this.flush(), 1500); } onBeforeUnload(): void { if (this.pendingPosition !== null) { chrome.runtime.sendMessage({ type: 'SAVE_POSITION_SYNC', url: location.href, position: this.pendingPosition }); } } private flush(): void { if (this.pendingPosition === null) return; chrome.storage.local.set({ [storageKey(location.href)]: { position: this.pendingPosition, savedAt: Date.now() } }); this.pendingPosition = null; } } https://example.com/article?utm_source=twitter and https://example.com/article are the same article. I strip tracking parameters before generating storage keys: const TRACKING_PARAMS = new Set([ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'fbclid', 'gclid', 'ref', 'source', ]); function normalizeUrl(url: string): string { try { const parsed = new URL(url); for (const param of TRACKING_PARAMS) { parsed.searchParams.delete(param); } parsed.pathname = parsed.pathname.replace(/\/$/, '') || '/'; return parsed.toString(); } catch { return url; } } Silent immediate restoration is startling. Instead, I show a banner only after the user starts scrolling: window.addEventListener('scroll', async () => { if (getScrollFraction() < 0.05) return; const saved = await getSavedPosition(location.href); if (!saved || saved.position < 0.1) return; showRestoreBanner(saved); }, { passive: true, once: true }); Banner conversion rate in testing: ~70%. Users who have started scrolling and haven't found their spot are genuinely happy to see the offer. ReadMark is free for up to 10 saved positions. The Pro plan ($4.99 one-time) removes the limit and adds export/import, tag organization, and full-text search across saved bookmarks. View on Chrome Web Store Other tools I've built: View on Chrome Web Store View on Chrome Web Store