Case study · Analytics
87 even days out of 88: the parity check that exposed double-counted sign-ups in GA4
A career platform's GA4 showed an even number of sign-ups on 87 days out of 88. A check that took minutes led to two tags, four places in the code, and the audit checklist at the end.
happymonday.ua- Client
- happymonday.ua, a Ukrainian career platform
- My part
- GA4 and GTM audit, with Claude Code
- Timeline
- Found Jul 28 · fixed Jul 29, 2026
- A month later
- GA4 saw 85% of sign-ups, and the gap was explained
The complaint was short. The client’s head of growth wrote that GA4 showed barely 30% of their sign-ups. The site is happymonday.ua, a Ukrainian career platform. Sign-ups are one of the five numbers we had agreed to get right, and Google Ads optimises for them.
I didn’t start with the reports. I started with the raw numbers. My agent pulled the sign-up event count for every day from May 1 to July 27 through the GA4 API: 88 days. Then I asked a question that sounds silly. How many of these numbers are even?
87 of them.
Why an even number gives the bug away
If each sign-up is counted once, a day’s total is even or odd by chance. Over 88 days you’d expect about 44 even ones. If each sign-up is counted twice, every day’s total is even. Always.
87 even days out of 88 is about as likely as calling 81 coin tosses in a row. It wasn’t luck. Something was sending every sign-up to GA4 twice.
Sign-up events in GA4 per day, May 1 – July 27, 2026
A normal-looking curve: higher on weekdays, lower at weekends, one spike in early June. 87 of the 88 daily counts were even numbers; the only odd one was June 18.
Look at the curve and you won’t see it. Weekdays are high, weekends are low, there’s one spike in early June. Double counting doesn’t change the shape of a curve. It changes the level, and you notice the level only when you compare it with something.
The one odd day was June 18. I don’t know why. Maybe one hit of a pair got lost on the way. One odd day out of 88 doesn’t change the conclusion, so I didn’t dig.
The check works for any event you count by day: sign-ups, purchases, form submits. It also works after a fix. Once doubling is gone, odd numbers should appear.
Why GA4 counted sign-ups twice
Next came the tag manager container and the site’s code. Claude Code did the reading: the container is one large JSON export, the site is a WordPress theme. I checked what it found.
Two tags listened for the sign-up event. One waited for that exact event name. The other was a catch-all: it forwarded every event whose name started with “user” (the trigger was ^user.*). The sign-up event’s name started with “user”. So every sign-up went to GA4 twice.
The same catch-all let through a junk event that fired on the pages of logged-in users and measured nothing. Over a month there was about one of those for every five page views in the whole property.
The code had problems of its own. It sent the sign-up event from four places, at different steps of signing up:
- Two fired on the “check your email” page, before the person had confirmed anything.
- Two fired after a Google sign-up, but only if a marker in the URL survived a chain of redirects. Often it didn’t, and that sign-up was never counted.
So GA4 missed a large share of sign-ups and counted the rest twice. My estimates say even the “30%” was flattering, but I couldn’t check them against the database.
The same few days turned up more of the same kind:
- Page view was marked as a key event, so every page view counted as a conversion. Five more key events were dead.
- The B2B lead form sent its own event, but no tag listened for it. B2B leads were never tracked.
- User type, candidate or company, arrived as “(not set)” for everyone. The tag sent it with each event, while GA4 had it registered as a property of the user. B2C and B2B were mixed together.
- User ID was empty in every event. The code wrote
userId, the tag readuserID, and the ID appeared on the page only after the analytics tag had already fired. - The button that sends a candidate to the employer’s site has an icon and text inside. The click trigger matched only the link itself, so clicks on the icon or the text were lost.
The fix: one sign-up, one event
We fixed the main part in one day, July 29.
The container change came first. My agent built it as an import file and ran 29 automated checks on it, including one that confirmed every other tag, trigger and variable stayed exactly as it was. I imported it by hand and published it at two in the morning. It paused the duplicate tag and narrowed the catch-all to two exact event names. It added a tag for the B2B lead form, moved user type to where GA4 expected it, and made the employer-site click count anywhere on the button.
Then the code. I wrote a one-page task, and the client’s team shipped it the same day. All five old pushes went away (the fifth sent a differently named event). In their place is one event, and the server decides when to send it: once per new user, on the first page they see with a confirmed account. Then the server marks that user in the database, so the event can never fire twice. The method (email or Google) and the user type travel as parameters of that one event.
In GA4, page view and the five dead events lost their key-event mark. The new events got it.
Before
After
One detail matters later. The event still travels through the browser, so an ad blocker can still stop it. But the server now knows exactly whom it sent the event for. That turns “GA4 shows less than the database” from a guess into arithmetic.
The first proof came the next day: hours with an odd number of sign-ups. With doubling, that can’t happen. Candidates and companies showed up separately for the first time, and Google sign-ups arrived with their method.
One step lagged. The Google Ads sign-up goal was built on the old event. Importing the new event into Ads was on the to-do list, but it happened almost two weeks later, and until then the goal showed zero. Switch an event and everything that reads it on the same day.
GA4 vs the database: where the other 15% go
A month later I compared GA4 with the site’s database, metric by metric, for August 1–30. The database is the truth here: an application is a row, a sign-up is a user.
How much of each real action GA4 recorded, August 2026
Against the site's database: job applications 94%, clicks to the employer's site 92%, sign-ups 85%, company sign-ups 83%, B2B lead forms 100%.
GA4 will never match a database exactly. The useful question is whether the gap is stable. A bug jumps around. A stable gap is the normal loss on the visitor’s side: ad blockers, and tabs closed before the hit leaves.
Day by day, GA4 recorded 94% of job applications, give or take 2.2 points, across all 30 days. July looked the same. Clicks to employers’ sites give the cleanest proof. The site records each of those clicks with its own request to its own domain, and the GA4 hit fires on the same click. Same click, two records. Blockers cut only the GA4 one.
For sign-ups the gap is wider, and here the server’s mark pays off. I could split the 15% exactly instead of guessing.
Where the missing 15% of sign-ups go
Of all sign-ups in the database, the site sent the event for 95%, and GA4 recorded 85%.
- 5% of sign-ups never fire the event, by design. Most of them, 4.5% of all sign-ups, are people who sign up with email and never confirm it, or never come back signed in. That’s also a list of people the client can remind.
- For another 10%, the site sent the event and it never arrived. That’s 10.5% of the events the site sent, the same kind of loss as everywhere else.
The rules for reports became simple. Volumes come from the database. Sources, behaviour and funnels come from GA4, read with a ratio of about 0.9 in mind.
The share of sent sign-up events that reaches GA4 (89.5% in August) is now checked every week against a threshold written in advance: 84% or more is noise, 80% or less means a closer look. In mid-September it fell to 77% for a week, worst for sign-ups through a quiz page. It came back to 91% on the day the team made the site’s scripts load faster. That’s a coincidence in time, not proof. But the weekly check caught the dip while it was happening.
And the doubles are gone. In a month of data, a single user got the sign-up event twice.
Two months later: three more broken events
In late September we checked all 38 event names GA4 had received since the fix. Sign-ups were fine. Three other events were not:
- “Vacancy payment” was a click on the Publish button in the job form, edits included. It showed about 15 times more “payments” than there were paid orders, and the payment amount was empty in every event.
- “Guide purchase” fired on any thank-you page after a payment. 84% of these “purchases” were payments for job posts, and reloading the page counted one order up to seven times.
- Job alert subscriptions hadn’t been counted for a year. The tag waited for a button with the old text and the old style. The site had changed both; the tag kept waiting.
These take more care than they look. Two of them are key events, and GA4 feeds the client’s Google Ads. Change a key event and you change what campaigns optimise for, and you lose the comparison with the past. So the fix adds new, correct events next to the old ones: a payment counted on the success page, once per order number. Two weeks of data, a check against the database, and only then the switch. As of early October, that package is ready and waiting for the go-ahead.
The same bugs on my own sites
None of this is special to big sites. I’ve had the same bugs on small ones.
On plants.place, my AI agent reported the events as working. When I asked whether data was actually arriving, it checked again. The events reached the page’s data layer, but no trigger in the container picked them up. While fixing that, it set up seven tags with a GA4 measurement ID it had typed by hand. The ID was wrong. The agent caught it on its own. Had it not, nothing would have complained: tags fire, requests go out, data lands nowhere. Now the ID always comes from the API.
On a small network of my own content sites, GA4 counted about five times fewer outbound clicks than the partner programs on the other end. Now I count those clicks on my own redirect, which a blocker never sees.
GA4 audit checklist
Every line grew from a real bug in this story. Start with the first one: it takes minutes.
| Check | What to do | The bug behind it |
|---|---|---|
| Even counts | Count an event by day. If nearly every day is even, it’s sent twice. | 87 even days out of 88. Two tags forwarded every sign-up. |
| Pattern triggers | Find triggers that match event names by pattern, and list what they let through. | “Any event starting with user” made the second copy of each sign-up and let a junk event in. |
| Orphans | For each event the site sends, find its tag. For each tag, check that it fired in the last 30 days. | The B2B lead form sent an event no tag listened to. A subscription tag waited for a button that had changed: a year of zeros. |
| Key events | Each key event is a result, not a view or a click. | Page view was a key event. “Vacancy payment” was a button click, about 15 times the real payments. |
| Purchases | A purchase event fires once per order, for one product. | “Guide purchase” fired on every thank-you page: 84% were other payments, and reloads counted one order up to seven times. |
| Timing | The event fires after the result, not before. | Sign-up fired on the “check your email” page, before anything was confirmed. |
| Redirects | The event survives redirects, including sign-in with Google. | Google sign-ups depended on a URL marker surviving a chain of redirects. Often it didn’t. |
| Empty values | Check the share of “(not set)” and empty values in the dimensions you report on. | User type was “(not set)” for everyone: wrong scope. User ID was empty in every event: userId against userID. |
| Click targets | A click trigger catches clicks on the icon and the text inside a button. | Clicks on the icon inside the employer-site button were lost. |
| Measurement ID | Send a test event and watch it arrive. Take the ID from the API, not from memory. | On my own site, events reached the data layer but no trigger picked them up, and the ID in seven new tags was typed by hand and wrong. |
| Second count | Compare with the database or a partner’s count, by day, as a ratio. Stable means loss. Jumping means a bug. | GA4 sees 94% of applications, give or take 2.2 points a day. On my own sites it saw about five times fewer clicks than the partners did. |
| What reads it | Before replacing an event, find everything that imports it, and switch it the same day. | After the fix, the Google Ads sign-up goal sat at zero for almost two weeks. |
| Closed days | Judge yesterday, not today. | The GA4 Data API lags by hours. Once it made us conclude that events had stopped. They hadn’t. |
What I can’t prove
- Why June 18 was odd. One day doesn’t change the conclusion, so I didn’t investigate.
- What exactly the 10.5% is. Ad blockers and tabs closed too early are the usual explanation, and a stable daily ratio fits it. I didn’t split it by cause.
- That the B2B shares will hold. Company sign-ups and lead forms are small numbers. Lead forms stayed close to 100% in the September check; company sign-ups I haven’t rechecked.
- How many sign-ups GA4 caught before the fix. I have estimates, but none I could check against the database for the same days, so I don’t quote one.
What I’d do differently
- Switch everything that reads an event on the same day as the event. The Google Ads goal would not have gone blind for almost two weeks.
- Check every key event against a second count, not only the five we agreed to fix. The payment and guide events were wrong, and nobody looked, because they weren’t on the list.
- Run the parity check first, before opening any report. It took minutes and pointed straight at the cause.