惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

人人都是产品经理
人人都是产品经理
博客园_首页
IT之家
IT之家
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Vercel News
Vercel News
美团技术团队
D
Docker
WordPress大学
WordPress大学
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Cloudflare Blog
Y
Y Combinator Blog
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
G
Google Developers Blog
爱范儿
爱范儿
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
月光博客
月光博客
MongoDB | Blog
MongoDB | Blog
S
SegmentFault 最新的问题
GbyAI
GbyAI
Hugging Face - Blog
Hugging Face - Blog
Microsoft Azure Blog
Microsoft Azure Blog
A
About on SuperTechFans

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
I Built a Chrome Extension That Remembers Where You Stopp...
SHOTA · 2026-05-10 · via DEV Community

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