AL.
🇪🇸 ES
Back to blog
Security · 8 min read

How I Caught a Bot Swarm Hiding in My PostHog Analytics

A real story of detecting bot traffic with PostHog: how a viewport that did not match the screen size gave the crawlers away, and how I pinned them again once they patched that tell using user agent, OS, synthetic referrers, and sub-second event bursts.


One of my sites serves around a million pages of public information. I cannot get into what it is, but the shape of the problem is easy to describe: a lot of URLs, all of them worth scraping if you are the kind of person who scrapes things. So one week a swarm showed up and started walking the whole thing.

The first sign was the bill. Traffic climbed in a way that did not match anything I had shipped or promoted. The second sign was my analytics, which suddenly looked great in the way that should make you suspicious. Sessions were up, page views were up, and none of it meant anything, because most of it was a machine reading pages nobody would ever read.

I use PostHog, and I did not want to install a bot-fighting product or throw everything behind a WAF and hope. I wanted to actually identify the traffic first, so I could measure it, remove it from my numbers, and then decide what to block. This is the story of how I found them, how they adapted, and how I found them again.

Why bots are annoying to catch

The frustrating part is that a well-made crawler looks like a person. It requests a page, it has a user agent, it can run JavaScript, it fires the same events a browser fires. If you only look at one property at a time, everything checks out. A user agent that says Chrome is not proof of anything. A referrer that says Google is not proof of anything either.

The trick, at least the one that worked for me, is to stop looking at single properties and start looking at combinations that a real browser cannot produce. A human sitting at a real device carries a bundle of physical facts about that device. Those facts have to agree with each other. When they do not, you are not looking at a device, you are looking at something pretending to be one.

The first tell: a viewport bigger than the screen

PostHog captures a set of properties on events automatically, and two of them turned out to be the whole game early on: the viewport size and the screen size.

The viewport is the visible area of the page inside the browser window. The screen is the physical display the browser is running on. There is a hard physical rule connecting them: your viewport cannot be larger than your screen. The browser window lives inside the monitor. You can make the window smaller than the screen, you can never make it bigger.

The bots reported a viewport of 1920x1280 sitting on a screen of 800x600.

That is impossible on real hardware. Nobody has an 800x600 monitor rendering a window that is more than twice as wide as the display it lives on. What had happened is that whoever built the crawler set the viewport to a nice modern default to make pages render correctly, and left the screen dimensions at some headless-browser or emulator default that never got touched. The two numbers were configured by different parts of their setup and nobody made them agree.

In PostHog this was easy to turn into a filter. I looked at the user properties on the sessions and built a cohort where the viewport width was greater than the screen width. On a normal population that condition should match essentially nobody. On this traffic it matched an enormous, tidy block of sessions. I finally had a number for the swarm, and I could subtract it from my real numbers.

They noticed, and they fixed it

This is the part people do not warn you about. If your bot detection is good enough to matter, the people running the bots will notice their traffic getting handled, and they will adapt. It is a cat and mouse game, and you do not get to win it once and walk away.

A little while after I started filtering on the viewport mismatch, the tell disappeared. The screen size started coming through as a normal value that matched the viewport. Someone on the other side had looked at their setup, or looked at how sites were catching headless browsers, and set the screen dimensions to something believable. The impossible geometry was gone.

My clean cohort stopped catching them. For a moment the traffic looked like people again.

The second tell: a combination nobody real would produce

So I went back to the same principle. One property proves nothing, but the right combination of properties describes a single machine, not a crowd of humans. I stopped hunting for one magic flag and started looking at the shape of the traffic across several dimensions at once.

Four things lined up:

  • A very specific user agent. Not “Chrome,” but one exact user agent string, down to the version, showing up far more often than any real version distribution would ever produce. Real visitors spread across dozens of browser and OS builds. This was one string, repeated.
  • Linux as the operating system. There is real human Linux traffic, and I am not claiming Linux means bot. But for this particular audience the share of Linux was wildly out of proportion, and it clustered with the exact user agent above instead of spreading out the way genuine Linux users do.
  • A synthetic referrer. Every one of these sessions arrived with a referrer set, and it was the same fabricated source rather than the messy mix of direct, search, and social you see from real people. It was there to look legitimate, which is exactly what made it stand out once I stopped trusting referrers on their own.
  • Sub-second event bursts in tight time windows. This was the clincher. A real person loads a page and then does human things: they pause, they scroll, they read, seconds pass between interactions. These sessions fired multiple interaction events inside the same second, over and over, and the whole session lived inside a tiny time window. No hands and eyes move like that. That is a loop running.

Any one of those in isolation is defensible. A specific user agent happens. Linux happens. A referrer happens. Fast events happen. But a session that is all four at once, the exact same user agent, on Linux, with the same synthetic referrer, firing sub-second event storms, is not a coincidence of four independent facts. It is one script wearing four costumes.

Building the filter in PostHog

Turning that into something usable in PostHog was a matter of stacking the conditions into one cohort and letting the AND do the work. The individual conditions are loose on purpose. Each one, alone, would sweep up innocent traffic. Together they collapse onto the swarm and almost nothing else.

The sub-second burst was the part that needed the most thought, because a single event property does not capture it. What captures it is the timing between events inside a session: many interactions landing in the same second, and a session duration measured in a couple of seconds rather than minutes. Looking at the events on a session timeline made it obvious which sessions were a person and which were a for-loop, and from there I could describe the pattern as a filter instead of eyeballing it.

Once the cohort was defined, everything I actually wanted became easy. I could exclude it from my dashboards so my real engagement numbers meant something again. I could quantify exactly how much of the load was the swarm, which is the number that matters when you are staring at a bill. And I had a precise, evidence-backed description of the traffic to hand to the layer that does the actual blocking, instead of a vague “there are some bots.”

What I would tell you if you are in the same spot

The lesson that stuck with me is that you catch bots on consistency, not on any single signal. A real visitor is a physical device held by a person, and everything about that device has to hang together: the viewport fits inside the screen, the browser spread looks like a population, the referrers are a mess, and the timing has human gaps in it. A bot is a configuration, and configurations have seams. Your job is to find two facts that are not allowed to be true at the same time.

The other lesson is that this is not a one-time fix. The viewport trick worked until it did not. When they patched it, the answer was not a better single trick, it was more dimensions stacked together, because every tell you add is another seam they have to get right, and getting all of them right at once is a lot harder than fixing one screen-size default.

If you run something big enough to be worth scraping, PostHog already has most of what you need to see this. You do not need a fancy product to start. You need to stop trusting properties one at a time and start asking whether they can all be true at once.

If this saved you a confusing afternoon staring at your analytics, pass it along to someone else who is watching their traffic climb for no good reason.