DEV Community

Cover image for Why My Analytics Was Logging Every Page Visit Twice (And How I Fixed It)
Vicente G. Reyes
Vicente G. Reyes

Posted on • Originally published at vicentereyes.org

Why My Analytics Was Logging Every Page Visit Twice (And How I Fixed It)

I built a custom analytics system into my portfolio backend — a Django REST API that records page visits — and wired the React frontend to call it whenever someone lands on a project or blog post detail page. It worked, except for one problem: every visit was logged twice.

This is the story of chasing the wrong root cause, wasting time on a fix that didn't work, and landing on a dead-simple solution that lives outside React entirely.

The Setup

The backend is a Django REST API with a PageVisit model. The frontend calls a single endpoint whenever a user lands on a detail page:

// POST /api/analytics/track/
{ "page_type": "project", "object_id": 33 }
Enter fullscreen mode Exit fullscreen mode

On the React side, the call lives in a useEffect inside ProjectPage:

useEffect(() => {
  if (project?.id) api.trackPageView('project', Number(project.id));
}, [project?.id]);
Enter fullscreen mode Exit fullscreen mode

The project data comes from a useProject hook that reads from localStorage cache first, then fetches from the API.

The Symptom

After visiting two project pages, the admin showed this:

637  Project  33
636  Project  33
635  Personal Projects  -
634  Project  30
633  Project  30
Enter fullscreen mode Exit fullscreen mode

Two records per project visit, every single time.

First Suspect: React StrictMode

My first instinct was React StrictMode. In development, <React.StrictMode> intentionally double-invokes effects to surface side effects — it mounts the component, runs cleanup, then remounts it. If the effect fired on both the original mount and the simulated remount, that would explain exactly two records per visit.

The standard fix is a useRef guard: store the last tracked ID in a ref, and skip the call if it matches.

const trackedProjectId = useRef<string | null>(null);

useEffect(() => {
  if (project?.id && trackedProjectId.current !== String(project.id)) {
    trackedProjectId.current = String(project.id);
    api.trackPageView('project', Number(project.id));
  }
}, [project?.id]);
Enter fullscreen mode Exit fullscreen mode

The logic: on the first run, the ref is null, so we track and set it to "33". On StrictMode's simulated remount, React preserves state and refs, so the ref is still "33" — the condition is false, and the second call is skipped.

I pushed this fix. The visits still doubled.

The Real Cause: A Full Remount, Not a Preserved One

React StrictMode's "double-invoke effects" behavior comes in two flavors. In the version I was thinking of, state and refs are preserved between the simulated unmount and remount — so a ref guard works perfectly. But if the component is fully remounting (fresh component instance, all state and refs reset to initial values), the ref starts as null again on every mount, and the guard is useless.

That's what was happening here. The component wasn't getting StrictMode's gentle "simulate and preserve" treatment — it was being torn down and recreated. Each fresh mount started with trackedProjectId.current = null, saw a project with id = 33, passed the guard, and fired the tracking call.

I confirmed this by looking at the hook. The useProject hook initializes from localStorage cache:

function useFetchSingle<T>(fetcher: () => Promise<T>, cacheKey: string) {
  const [data, setData] = useState<T | null>(() => readCacheSingle<T>(cacheKey));
  // ...
  useEffect(() => {
    fetcher().then((res) => { setData(res); /* write cache */ });
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

On a full remount, useState runs its initializer again. If the project is cached, data starts as the cached project immediately — project.id is available on the very first render, so the tracking effect fires right away. Then the component remounts again from scratch, the same initializer runs, the same project loads from cache, and the effect fires again.

A ref-based guard can't survive a full remount. By definition, refs reset to their initial value on a new component instance.

The Fix: Move Deduplication Outside React

If the component lifecycle can't be trusted to hold state across mounts, the guard needs to live somewhere that can: a module-level variable. In JavaScript, module-level variables are initialized once per page load and persist for the lifetime of the session, completely independent of component mount/unmount cycles.

I added a Set at the top of api.ts, outside the api object:

const _tracked = new Set<string>();

export const api = {
  // ...
  trackPageView: (
    pageType: 'professional_projects' | 'personal_projects' | 'audio_works' | 'blog_post' | 'project',
    objectId?: number,
  ) => {
    const key = `${pageType}:${objectId ?? ''}`;
    if (_tracked.has(key)) return Promise.resolve();
    _tracked.add(key);
    setTimeout(() => _tracked.delete(key), 3000);
    return fetch(`${BASE_URL}/analytics/track/`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ page_type: pageType, ...(objectId != null && { object_id: objectId }) }),
    }).catch(() => {});
  },
};
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. On first call with "project:33", the key isn't in the set — we add it and fire the request.
  2. Any duplicate call within the next 3 seconds hits _tracked.has(key) and returns immediately without a network request.
  3. After 3 seconds, the key is removed, so legitimate return visits (user navigates away and comes back) are recorded correctly.

The 3-second window is long enough to absorb any double-mount behavior (which happens within milliseconds), and short enough that it doesn't suppress real repeat visits.

Component Code Stays Clean

Because the deduplication now lives in api.ts, the component code doesn't need any guard logic:

// ProjectPage.tsx
useEffect(() => {
  if (project?.id) api.trackPageView('project', Number(project.id));
}, [project?.id]);
Enter fullscreen mode Exit fullscreen mode
// BlogPostPage.tsx
api.blogPost(lookup).then((data) => {
  setPost(data);
  if (data.id) api.trackPageView('blog_post', Number(data.id));
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Clean call sites, no leaking implementation details into components.

Takeaway

When a useRef guard doesn't fix a double-invocation problem, the component is fully remounting — not doing React StrictMode's state-preserving remount. In that case, any component-level guard will be reset on every mount and is useless.

The fix is to move the deduplication to a layer that outlives the component: a module-level variable. It's outside React's rendering model entirely, so no mount/unmount cycle can touch it.

Top comments (0)