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 Core Problem: Scroll Position Is Not a URL
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
scrollYvalue 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 <= 0) return 0;
return Math.min(scrollTop / scrollable, 1.0);
}
function restoreScrollFraction(fraction: number): void {
const scrollable =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
window.scrollTo({ top: fraction * scrollable, behavior: 'instant' });
}
Challenge 1: Restoration Timing
The first version restored position on DOMContentLoaded. This broke on almost every modern site because CSS, images, and third-party widgets shift the layout after DOMContentLoaded fires.
The solution is to wait for visual stability using a cascade:
async function waitForStability(): Promise<void> {
await new Promise<void>(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.
Challenge 2: SPA Navigation Detection
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.
Challenge 3: Auto-Save Without Spamming Storage
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<typeof setTimeout> | 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;
}
}
Challenge 4: Storage Key Normalization
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;
}
}
UX Decision: When to Show the Restore Banner
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.
Try ReadMark
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.
Other tools I've built:
Top comments (0)