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)

Ref guard limitations in React StrictMode

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 (3)

Collapse
 
merbayerp profile image
Mustafa ERBAY

The two-flavors-of-StrictMode distinction is the part most write-ups miss — "a ref guard can't survive a full remount, by definition" is the line I'll be quoting to people. Genuinely nice debugging.

One thing I'd add from the backend side, since I run a similar custom analytics setup: the module-level Set is the right client-side fix, but I'd treat it as the first of two layers, not the only one. A client guard only protects within a single page-load / JS context — it won't catch dupes from request retries, a user with two tabs open, or a flaky network that resends. So I keep a cheap server-side dedup too: a short-TTL key (or a unique constraint) on something like visitor_hash + page + time_bucket. Same visitor hits the same page inside the bucket → the insert is a no-op.

The nice side effect is that the server-side guard also drops bot/duplicate noise that never even ran your React (crawlers hitting /analytics/track/ directly — ask me how I know 🙂). The 3-second client window + a server bucket together cover both "double-mount" and "double-request" without suppressing real return visits.

Either way — clean fix, and moving the guard outside React's lifecycle is exactly the right instinct.

Collapse
 
highcenburg profile image
Vicente G. Reyes

That's a great point, and I completely agree that client-side deduplication shouldn't be the only line of defense.

In my case, the module-level Set was specifically solving the React StrictMode development issue, where the same page view was being tracked twice from the same mount cycle. But as you mentioned, that doesn't address duplicate requests caused by retries, multiple tabs, network hiccups, or direct hits to the tracking endpoint.

The backend already has some protections in place, but I like your visitor_hash + page + time_bucket approach because it treats analytics tracking as an idempotent operation rather than assuming the client will always behave perfectly. That's a much stronger design overall.

I also learned the hard way that once analytics data gets polluted, it's surprisingly difficult to clean up after the fact. It's much cheaper to prevent duplicates at multiple layers than to explain inflated metrics later.

And yes, bots hitting analytics endpoints directly is one of those problems nobody thinks about until they see it in their logs. 😄

I may add a note about server-side deduplication in the article since the post focuses mainly on the React/StrictMode debugging side, but production-grade analytics definitely benefits from both layers working together.

Collapse
 
merbayerp profile image
Mustafa ERBAY

Really appreciate you running with this, Vicente 🙏

You put your finger on the exact thing the post under-sells: the module-level Set only ever fixed the development-time StrictMode double-mount. Retries, multiple tabs, a flaky network, or a bot hitting the endpoint directly — none of those care about a client-side Set.

The idempotency framing is the right one. I've started thinking of the tracking endpoint less as "record this event" and more as "make sure this event exists exactly once." A visitor_hash + page + time_bucket unique key expresses that cleanly — the write becomes naturally idempotent and the client is free to be as flaky as it wants.

And 100% on the cleanup pain. Once the data's polluted you're writing dedup queries over historical rows and second-guessing every spike. Cheap to prevent, expensive to explain after the fact.

You've convinced me — I'll add a short server-side section so nobody walks away thinking the client Set is the whole story. Thanks for pushing it there; this is the kind of comment that genuinely makes a post better. 🐢

Some comments may only be visible to logged-in visitors. Sign in to view all comments.