DEV Community

SHOTA
SHOTA

Posted on

I Built a Chrome Extension That Remembers Where You Stopped Reading

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 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 <= 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' });
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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'))
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.

View on Chrome Web Store


Other tools I've built:

View on Chrome Web Store
View on Chrome Web Store

Top comments (0)