Try it Free

How We Found and Fixed a Hidden View Inflation Bug

You ship a notification feature and it works. People get emails when someone watches their video. The analytics dashboard counts views correctly. You move on.

Months later, a user uploads a video and watches the view count climb while nobody else is watching. The notification emails stack up — one every 10 seconds, all from the same viewer. Something is recording phantom views.

This is the story of a bug that hid in plain sight in SendRec, our open-source screen recording platform.

The feature that introduced the bug

SendRec transcribes videos automatically using Whisper. Transcription takes time — anywhere from a few seconds to a few minutes depending on the video length. If you share a video immediately after recording, the viewer might open the watch page before the transcript is ready.

To handle this, the watch page polls the API for transcript status:

var pollInterval = setInterval(function() {
    fetch('/api/watch/' + shareToken)
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.transcriptStatus === 'ready' ||
                data.transcriptStatus === 'failed') {
                clearInterval(pollInterval);
                window.location.reload();
            }
        });
}, 10000);

Every 10 seconds, the JavaScript fetches the watch API endpoint to check if the transcript is done. When it is, the page reloads to show the transcript panel.

The problem: the watch API handler records a view on every request.

Why each poll recorded a view

The watch page has two layers. The server-rendered HTML page (WatchPage handler) loads first and records a view. Then the JavaScript on the page calls the JSON API (Watch handler) for dynamic data like transcript status.

Both handlers run the same view-recording code:

go func() {
    ip := clientIP(r)
    hash := viewerHash(ip, r.UserAgent())
    h.db.Exec(ctx,
        `INSERT INTO video_views (video_id, viewer_hash) VALUES ($1, $2)`,
        videoID, hash,
    )
    h.resolveAndNotify(ctx, videoID, ownerID, ownerEmail,
        creator, title, shareToken, viewerUserID, viewNotification)
}()

The server-rendered page records one view — correct. But the transcript polling calls the same API endpoint every 10 seconds, and each call records another view and triggers another notification.

A viewer watching a 3-minute video while the transcript processes would generate 18 extra views and 18 notification emails.

Why it was hard to spot

The bug only triggers when all three conditions are true:

  1. The video has a processing transcript (already-transcribed videos don’t poll)
  2. The viewer is not the video owner (owner views are skipped for notifications)
  3. The owner has view notifications enabled (most users leave them off)

Condition 1 is temporary — transcripts finish within minutes and the polling stops. Condition 3 means most users never see the duplicate emails. The inflated view counts blend in because you’d need to cross-reference the timestamp pattern (a new view every 10 seconds from the same hash) to notice something is wrong.

The fix

The simplest fix: tell the API handler to skip view recording when the request is a transcript poll. We added a query parameter:

fetch('/api/watch/' + shareToken + '?poll=transcript')

And a check in the handler:

if r.URL.Query().Get("poll") != "transcript" {
    go func() {
        // record view and notify
    }()
}

When ?poll=transcript is present, the handler returns the video data (including transcript status) but skips the INSERT and the notification. Normal watch requests without the parameter continue recording views as before.

We considered alternative approaches:

  • A separate /api/watch/{token}/transcript-status endpoint — cleaner separation, but adds a new route and handler for a single field. Over-engineering for this case.
  • Rate-limiting views by viewer hash — would fix inflation but adds complexity to the view-recording path. The poll parameter is simpler and makes the intent explicit.
  • Moving transcript polling to WebSockets — eliminates polling entirely, but WebSockets are a significant infrastructure addition for a feature that only matters during the brief transcription window.

The query parameter approach is three lines of code across two files. It’s obvious, testable, and doesn’t change the behavior of any other endpoint.

The test

func TestWatch_TranscriptPoll_SkipsViewRecording(t *testing.T) {
    // Set up mock with video query but NO view INSERT expectation
    mock.ExpectQuery(`SELECT v.id, v.title`).
        WithArgs(shareToken).
        WillReturnRows(/* video with transcript "processing" */)

    // Request with poll parameter
    req := httptest.NewRequest(http.MethodGet,
        "/watch/"+shareToken+"?poll=transcript", nil)
    r.ServeHTTP(rec, req)

    // Should return 200 with transcript status
    assert(rec.Code == http.StatusOK)
    assert(resp.TranscriptStatus == "processing")

    // If the handler tried to INSERT, the mock would have
    // an unexpected call — no INSERT expectation was set
}

The test verifies that poll requests return the expected data without attempting to record a view. The existing TestWatch_RecordsView test confirms that normal requests still record views, so both paths are covered.

Lessons

Shared handlers amplify bugs. The Watch handler served two purposes: providing video data for the SPA and providing transcript status for polling. The view-recording side effect was correct for the first purpose and harmful for the second. When you add polling to an endpoint that has side effects, the side effects multiply by the polling frequency.

Conditional bugs are invisible bugs. This required a processing transcript, a non-owner viewer, and enabled notifications — three conditions that rarely align during development and testing. If you’re building notification features, test with notifications enabled from day one.

View counts lie quietly. Nobody questions a view count that’s too high. If this bug had reduced view counts, someone would have noticed immediately. Inflation is silent.

Try it

SendRec is open source (AGPL-3.0) and self-hostable. The fix shipped in v1.36.0. The notification system code is in notification.go and the watch handler in video.go.