ProgrammingFeatured
Sep 10, 2026
5 min read
17 views

Built a Chrome Extension to View Facebook Stories Anonymously (Without Triggering "Seen")

Ever wanted to watch Facebook Stories without leaving a trace? Here's how we reverse-engineered Facebook's GraphQL transports to build a zero-leak, Manifest V3 anonymous story viewer.

Built a Chrome Extension to View Facebook Stories Anonymously (Without Triggering "Seen")

Open Source on GitHub: github.com/plitto007/FacebookUnseenStories

Ever wanted to catch up on a friend's Facebook Story without showing up in their viewer list? Whether you want to browse without social pressure, check updates from acquaintances, or quietly do competitor research, viewing stories anonymously is a frequent user need.

Building this for modern Facebook, however, is a tricky engineering puzzle. Facebook is a complex React Single Page Application (SPA) powered by internal GraphQL APIs and multiple network transports.

In this quick deep dive, we'll examine how Facebook's story tracking works under the hood and how we engineered a lightweight, Manifest V3 Chrome extension to view Stories completely undetected—without breaking media playback or violating browser security models.


1. How Facebook Tracks Story Views

When you watch a Story on Facebook or Messenger web, two distinct network actions occur:

  1. Content Fetching (Read):
    Facebook queries endpoints like StoriesViewerBucketPrefetcherMultiBucketsQuery or StoriesTrayQuery to load image and video assets. These must never be blocked, or the player will freeze on a black screen.
  2. Seen Status Mutation (Write):
    Once a story card displays, Facebook fires a GraphQL mutation:
    • StoriesUpdateSeenStateMutation
    • StoriesReaderUpdateSeenStateMutation
    • Payload actions containing stories_update_seen_state

This mutation notifies Facebook servers to log your account ID into the author's viewer tray.

sequenceDiagram
    autonumber
    actor User
    participant Player as Facebook Story Player
    participant Ext as Unseen Interceptor
    participant Server as Facebook Servers

    User->>Player: Opens Friend's Story
    Player->>Server: Fetches Story Media (Image/Video)
    Server-->>Player: Media Stream Loaded (Story Plays)
    Player->>Ext: Sends StoriesUpdateSeenStateMutation
    rect rgb(30, 41, 59)
        Note over Ext: Intercepts & Drops Seen Request
        Ext-->>Player: Returns Mock 200 OK Response
    end
    Note over Player: Story proceeds smoothly (no retry loop)
    Note over Server: Server never receives the seen receipt!

2. The Manifest V3 Hurdle

In Manifest V2, extensions could simply use chrome.webRequest.onBeforeRequest with blocking privileges. But in Manifest V3:

  • webRequestBlocking is deprecated for standard extensions.
  • declarativeNetRequest cannot inspect HTTP POST bodies (FormData or x-www-form-urlencoded).
  • All Facebook features route through the same unified /api/graphql/ endpoint. Blocking the URL pattern would break your entire Facebook feed, notifications, and the story viewer itself.

The Solution: Client-side network interception directly inside the browser's JavaScript runtime.


3. The Architecture: Main World Interception

Extensions usually run in an Isolated World, meaning modifying window.fetch inside a standard content script won't affect the page's scripts.

Using Manifest V3's "world": "MAIN", we inject our interceptor script (content-main.js) at document_start before Facebook's React runtime even loads.

{
  "content_scripts": [
    {
      "matches": ["*://*.facebook.com/*", "*://*.messenger.com/*"],
      "js": ["content/content-main.js"],
      "run_at": "document_start",
      "world": "MAIN"
    },
    {
      "matches": ["*://*.facebook.com/*", "*://*.messenger.com/*"],
      "js": ["content/content-isolated.js"],
      "run_at": "document_start"
    }
  ]
}

4. Multi-Layer Transport Blocking

Facebook relies on three distinct browser network APIs. We hook all three to ensure zero leaks:

A. Intercepting fetch()

const originalFetch = window.fetch;
window.fetch = async function (input, init) {
  const url = typeof input === 'string' ? input : input?.url || '';
  const body = init?.body;

  if (isStorySeenRequest(url, body)) {
    notifyBlocked(url);

    // Return a mock 200 OK so Facebook's React state won't crash or retry
    return new Response(
      JSON.stringify({
        data: {
          stories_update_seen_state: {
            success: true,
            __typename: 'StoriesUpdateSeenStateMutationResponse'
          }
        }
      }),
      { status: 200, headers: { 'Content-Type': 'application/json' } }
    );
  }

  return originalFetch.apply(this, arguments);
};

Why return a fake 200 OK?
If the network request fails or throws, Facebook's React error handlers trigger aggressive retries or freeze playback. Providing a clean { success: true } mock allows the story viewer to transition smoothly to the next slide.

B. Intercepting XMLHttpRequest (XHR)

For legacy components, we hook XHR.prototype.open and XHR.prototype.send, simulating a completed response (readyState: 4, status: 200).

C. Overriding navigator.sendBeacon()

When a tab is closed or navigated away, Facebook dispatches final view receipts via sendBeacon(). Overriding navigator.sendBeacon prevents last-second receipts from escaping when you close a tab.


5. Bridging to the Extension UI

The Main World interceptor cannot directly invoke Chrome Extension APIs. To bridge this gap, we use an Isolated World content script (content-isolated.js) communicating via window.postMessage:

graph LR
    MainScript["content-main.js<br/>(Main World)"] -- "postMessage (BLOCKED)" --> IsolatedScript["content-isolated.js<br/>(Isolated World)"]
    IsolatedScript -- "chrome.storage" --> Storage[("Local Storage")]
    IsolatedScript -- "chrome.runtime" --> SW["Service Worker"]
    SW --> Badge["Toolbar Badge (ON/OFF)"]
  • Config Sync: When you toggle the popup switch, chrome.storage.local notifies the page via postMessage.
  • Live Counter: Each blocked seen mutation increments the counter displayed on your extension popup and toolbar badge.

6. Privacy First: Zero Tracking

  • 100% Client-Side: No tokens, credentials, or personal messages are ever inspected or transmitted.
  • Scored Permissions: Only requires access to facebook.com and messenger.com.
  • No Background Resource Drain: The service worker stays idle until triggered by storage events.

🚀 Get the Extension & Source Code

The complete source code is open-source and ready to load as an unpacked extension:

🔗 GitHub Repository: https://github.com/plitto007/FacebookUnseenStories

# Clone the repository
git clone https://github.com/plitto007/FacebookUnseenStories.git

# Load into Chrome:
# 1. Open chrome://extensions/
# 2. Enable "Developer mode" (top right)
# 3. Click "Load unpacked" and select the project folder

If you find this project useful, feel free to star the repo on GitHub! ⭐

Related Articles