---
title: "How I Know You Read This"
description: "How blog sites knows read counts? Can I manipulate it by spaming the refreshing the page button? How the mechanism work behind the scenes?"
slug: "how-i-know-you-read-this"
url: "https://furkanbaytekin.dev/blogs/how-i-know-you-read-this"
author: "Furkan Baytekin"
category: "Software"
tags: []
published: 2024-12-23T10:12:08Z
updated: 2024-12-23T10:12:08Z
readTime: 6
cover: "https://myblogcms.furkanbaytekin.dev/uploads/cover/how-i-know-you-read-this+1734782203185"
---

In this blog post, we’ll explore how the **Observer** class works to track blog post views in a Next.js middleware. We’ll examine each code segment step-by-step, explaining what it does and why it’s used. By the end, you’ll have the full picture and complete code.

Let’s dive in!

---

## 1. **Setting the Stage with a Singleton**

The first part of the code sets up the **Observer** as a singleton.

```typescript
class Observer {
  static instance: Observer

  private constructor() {
    Observer.observationMap = {}
    Observer.lastMemoryClean = new Date()
  }

  public static getInstance(): Observer {
    if (!this.instance) this.instance = new Observer()
    return this.instance
  }
}
```

Here’s what’s happening:
- **Singleton Pattern**: The `Observer` class ensures that there’s only one instance of itself through the `getInstance` method. This is perfect for scenarios where you want shared state—like tracking observations—without creating multiple redundant instances.
- **Observation Map**: A static object `observationMap` is used to store which IPs have accessed which blogs. This is the backbone of our tracking system.
- **Memory Cleaning Settings**: Two constants determine when old data is removed to keep memory usage efficient.

---

## 2. Setting Up the Observer Options

Next, we define options that control the Observer’s behavior:

```typescript
  private static observationMap: ObserverMap
  private static lastMemoryClean: Date
  private static readonly CLEAR_OLDER_THAN = 1000 * 60 * 10 // 10 minutes
  private static readonly MAX_CLEAN_INTERVAL = 1000 * 60 * 10 // 10 minutes
```

- **Observation Map**: This object stores the IP address hashes (for privacy) and the last visit time for each blog. ObserverMap type is defined as:

```typescript
type ObserverMap = Record<
  ObserverIPHash, // Hashed IP address as string
  Record<
    BlogCardData["slug"], // Blog slug
    Date // Last visit time
  >
>
```

- `lastMemoryClean` stores the time of the last memory cleanup. This is used to determine when to clean up old observations. Clearing old observations at every request can be inefficient, so we do it periodically.

- **Memory Cleaning Settings**: These constants define when old observations are removed. The `CLEAR_OLDER_THAN` constant determines how long observations are kept, while `MAX_CLEAN_INTERVAL` sets the frequency of memory cleanup.

## 3. **Observing Requests**

The heart of the `Observer` lies in the `observe` method.

```typescript
public async observe(request: NextRequest) {
  const pathname = request.nextUrl.pathname

  if (!Observer.shouldTrackThisPage(pathname)) return

  const slug = this.getSlugToAdd(pathname)
  const ipHash = await this.getIpHash(request)

  if (!slug || !ipHash) return

  if (!this.hasRecentView(ipHash, slug)) {
    this.addView(ipHash, slug, new Date())
    this.incrementBlogView(slug)
  }

  if (this.doNeedMemoryClean()) this.clearOldObservations()
}
```

Here’s the breakdown:
- **Check If Tracking Is Needed**: Not all routes need to be tracked, so the method first checks if the page is a blog using `shouldTrackThisPage`.
- **Extract Slug and Hash**: The blog's slug is extracted from the URL, and the visitor’s IP is hashed for anonymity.
- **Check Recent Views**: If the user hasn’t recently viewed the blog, their visit is recorded, and the view count is incremented.
- **Memory Cleaning**: Periodically, old observations are removed to ensure optimal performance.

---

## 3. **Memory Management**

Let’s look at how observations are cleared:

```typescript
private clearOldObservations(): void {
  const now = new Date()

  for (const ipHash in Observer.observationMap) {
    const record = Observer.observationMap[ipHash]
    for (const slug in record) {
      if (now.getTime() - record[slug].getTime()
        > Observer.CLEAR_OLDER_THAN
      ) delete record[slug]
    }
    if (Object.keys(record).length === 0)
      delete Observer.observationMap[ipHash]
  }

  Observer.lastMemoryClean = now
}
```

Key points:
- **Efficient Cleanup**: This method iterates through the observation map, removing records older than the defined threshold.
- **Maintaining State**: Empty IP records are also cleaned up to avoid unnecessary memory usage.

---

### 4. **Handling Blog Views**

When a blog is visited, the view count is incremented, but only if it hasn’t been visited recently:

```typescript
private hasRecentView(ipHash: ObserverIPHash, slug: BlogCardData["slug"]): boolean {
  const record = Observer.observationMap[ipHash]
  if (!record || !record[slug]) return false

  const lastView = record[slug]
  return new Date().getTime() - lastView.getTime()
    <= Observer.CLEAR_OLDER_THAN
}

private addView(ipHash: ObserverIPHash, slug: BlogCardData["slug"], date: Date): void {
  if (!Observer.observationMap[ipHash])
    Observer.observationMap[ipHash] = {}
  Observer.observationMap[ipHash][slug] = date
}

private incrementBlogView(slug: BlogCardData["slug"]): void {
  Drawer.increseBlogView(slug)
}
```

Drawer is a repository class that handles blog data. If you aren’t familiar with repository classes, go check out my blog post on them! [Why Use Repository Classes?](https://furkanbaytekin.dev/blogs/software/why-use-repository-classes)

## What’s Happening?
- **Avoiding Spamming**: The `hasRecentView` method ensures that views aren’t counted repeatedly within a short timeframe.
- **Tracking Visits**: Each unique visit is logged in `observationMap`.
- **Incrementing Counts**: The `Drawer.increaseBlogView` method updates the blog’s view count.

---

### 5. **Working with IP Addresses**

To ensure privacy, IP addresses are hashed before being stored:

```typescript
private async getIpHash(request: NextRequest): Promise<ObserverIPHash | null> {
  const ip = this.getIpAddress(request)
  if (!ip) return null

  const data = new TextEncoder().encode(ip)

  const hashBuffer = await crypto.subtle.digest("SHA-256", data)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}

private getIpAddress(request: NextRequest): string | null {
  return request.headers.get("x-forwarded-for")
}
```

- **Hashing for Privacy**: IPs are hashed with SHA-256, ensuring anonymity while maintaining uniqueness.
- **Getting IPs**: The `x-forwarded-for` header is used to extract the client’s IP address.

---

### Full Code

Here’s the complete code we’ve walked through:

```typescript
import { NextRequest } from "next/server"
import Drawer from "../Drawer"

class Observer {
  static instance: Observer
  private static observationMap: ObserverMap
  private static lastMemoryClean: Date
  private static readonly CLEAR_OLDER_THAN = 1000 * 60 * 10 // 10 minutes
  private static readonly MAX_CLEAN_INTERVAL = 1000 * 60 * 10 // 10 minutes

  private constructor() {
    Observer.observationMap = {}
    Observer.lastMemoryClean = new Date()
  }

  public static getInstance(): Observer {
    if (!this.instance) this.instance = new Observer()
    return this.instance
  }

  public async observe(request: NextRequest) {
    const pathname = request.nextUrl.pathname

    if (!Observer.shouldTrackThisPage(pathname)) return

    const slug = this.getSlugToAdd(pathname)
    const ipHash = await this.getIpHash(request)

    if (!slug || !ipHash) return

    if (!this.hasRecentView(ipHash, slug)) {
      this.addView(ipHash, slug, new Date())
      this.incrementBlogView(slug)
    }

    if (this.doNeedMemoryClean()) this.clearOldObservations()
  }

  private clearOldObservations(): void {
    const now = new Date()

    for (const ipHash in Observer.observationMap) {
      const record = Observer.observationMap[ipHash]
      for (const slug in record) {
        if (now.getTime() - record[slug].getTime()
          > Observer.CLEAR_OLDER_THAN
        ) delete record[slug]
      }
      if (Object.keys(record).length === 0)
        delete Observer.observationMap[ipHash]
    }

    Observer.lastMemoryClean = now
  }

  private hasRecentView(
    ipHash: ObserverIPHash,
    slug: BlogCardData["slug"]
  ): boolean {
    const record = Observer.observationMap[ipHash]
    if (!record || !record[slug]) return false

    const lastView = record[slug]
    return new Date().getTime() - lastView.getTime()
      <= Observer.CLEAR_OLDER_THAN
  }

  private addView(
    ipHash: ObserverIPHash,
    slug: BlogCardData["slug"],
    date: Date
  ): void {
    if (!Observer.observationMap[ipHash])
      Observer.observationMap[ipHash] = {}
    Observer.observationMap[ipHash][slug] = date
  }

  private doNeedMemoryClean(): boolean {
    const now = new Date()
    return now.getTime() - Observer.lastMemoryClean.getTime()
      >= Observer.MAX_CLEAN_INTERVAL
  }

  private getSlugToAdd(pathname: string): BlogCardData["slug"] | null {
    const match = pathname.match(/\/blogs\/[^/]+\/([^/]+)/)
    return match ? match[1] : null
  }

  private static shouldTrackThisPage(pathname: string): boolean {
    return /^\/blogs\/[^/]+\/[^/]+$/.test(pathname)
  }

  private incrementBlogView(slug: BlogCardData["slug"]): void {
    Drawer.increseBlogView(slug)
  }

  private async getIpHash(
    request: NextRequest
  ): Promise<ObserverIPHash | null> {
    const ip = this.getIpAddress(request)
    if (!ip) return null

    const data = new TextEncoder().encode(ip)

    const hashBuffer = await crypto.subtle.digest("SHA-256", data)
    const hashArray = Array.from(new Uint8Array(hashBuffer))
    return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
  }

  private getIpAddress(request: NextRequest): string | null {
    return request.headers.get("x-forwarded-for")
  }
}
export default Observer
```

---

This middleware ensures that we accurately track blog views while maintaining user privacy. By using techniques like IP hashing, memory management, and a singleton structure, it’s both efficient and scalable.
