A very Minimal Google Analytics 4 Snippet

A very minimal google analytics 4 snippet allowing you to record page views, site searches, scrolls and file downloads on your website without loading official, heavy tracking library.

When Google announced that they would force us to move away from Universal Analytics to Google Analytics 4 I wasn’t happy. Due to a lack of alternatives in minimal analytics, loading official (bloated) tracking code that weighs 171kB (in my instance), which is liable for blocking by various AdBlockers, wasn’t something that I had been looking forward to.

I started searching for a solution. Due to the lack of it, I decided, by hit-and-miss approach, to create my own, and I think I did it. It currently weighs 3kB minified (version 1.10). Its main purpose is to track page views (page_view, session_start and first_visit) on our website in Google Analytics 4 property. Since version 1.06 it detects and tracks site searches (view_search_results), from 1.07 search query (search_term), from 1.09 scrolls (scroll) capturing scroll events each time when a visitor gets to the bottom of a page (90% and below) and from 1.10 it got ability to track <a href links to files with specified extensions (see below) and all these links where there is a download attribute specified independently of the extension of the file.

Minimal Analytics 4 - the code

Version 1.10.200923 (v1.10 from 20/09/2023)

enScroll=!1,enFdl=!1,extCurrent=void 0,filename=void 0,targetText=void 0,splitOrigin=void 0;const lStor=localStorage,sStor=sessionStorage,doc=document,docEl=document.documentElement,docBody=document.body,docLoc=document.location,w=window,s=screen,nav=navigator||{},extensions=["pdf","xls","xlsx","doc","docx","txt","rtf","csv","exe","key","pps","ppt","pptx","7z","pkg","rar","gz","zip","avi","mov","mp4","mpe","mpeg","wmv","mid","midi","mp3","wav","wma"];function a(e,t,n,o){const j="G-XXXXXXXXXX",r=()=>Math.floor(Math.random()*1e9)+1,c=()=>Math.floor(Date.now()/1e3),F=()=>(sStor._p||(sStor._p=r()),sStor._p),E=()=>r()+"."+c(),_=()=>(lStor.cid_v4||(lStor.cid_v4=E()),lStor.cid_v4),m=lStor.getItem("cid_v4"),v=()=>m?void 0:enScroll==!0?void 0:"1",p=()=>(sStor.sid||(sStor.sid=c()),sStor.sid),O=()=>{if(!sStor._ss)return sStor._ss="1",sStor._ss;if(sStor.getItem("_ss")=="1")return void 0},a="1",g=()=>{if(sStor.sct)if(enScroll==!0)return sStor.sct;else x=+sStor.getItem("sct")+ +a,sStor.sct=x;else sStor.sct=a;return sStor.sct},i=docLoc.search,b=new URLSearchParams(i),h=["q","s","search","query","keyword"],y=h.some(e=>i.includes("&"+e+"=")||i.includes("?"+e+"=")),u=()=>y==!0?"view_search_results":enScroll==!0?"scroll":enFdl==!0?"file_download":"page_view",f=()=>enScroll==!0?"90":void 0,C=()=>{if(u()=="view_search_results"){for(let e of b)if(h.includes(e[0]))return e[1]}else return void 0},d=encodeURIComponent,k=e=>{let t=[];for(let n in e)e.hasOwnProperty(n)&&e[n]!==void 0&&t.push(d(n)+"="+d(e[n]));return t.join("&")},A=!1,S="https://www.google-analytics.com/g/collect",M=k({v:"2",tid:j,_p:F(),sr:(s.width*w.devicePixelRatio+"x"+s.height*w.devicePixelRatio).toString(),ul:(nav.language||void 0).toLowerCase(),cid:_(),_fv:v(),_s:"1",dl:docLoc.origin+docLoc.pathname+i,dt:doc.title||void 0,dr:doc.referrer||void 0,sid:p(),sct:g(),seg:"1",en:u(),"epn.percent_scrolled":f(),"ep.search_term":C(),"ep.file_extension":e||void 0,"ep.file_name":t||void 0,"ep.link_text":n||void 0,"ep.link_url":o||void 0,_ss:O(),_dbg:A?1:void 0}),l=S+"?"+M;if(nav.sendBeacon)nav.sendBeacon(l);else{let e=new XMLHttpRequest;e.open("POST",l,!0)}}a();function sPr(){return(docEl.scrollTop||docBody.scrollTop)/((docEl.scrollHeight||docBody.scrollHeight)-docEl.clientHeight)*100}doc.addEventListener("scroll",sEv,{passive:!0});function sEv(){const e=sPr();if(e<90)return;enScroll=!0,a(),doc.removeEventListener("scroll",sEv,{passive:!0}),enScroll=!1}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementsByTagName("a");for(let t=0;t<e.length;t++)if(e[t].getAttribute("href")!=null){const n=e[t].getAttribute("href"),s=n.substring(n.lastIndexOf("/")+1),o=s.split(".").pop();(e[t].hasAttribute("download")||extensions.includes(o))&&e[t].addEventListener("click",fDl,{passive:!0})}});function fDl(e){enFdl=!0;const t=e.currentTarget.getAttribute("href"),n=t.substring(t.lastIndexOf("/")+1),s=n.split(".").pop(),o=n.replace("."+s,""),i=e.currentTarget.text,r=t.replace(docLoc.origin,"");a(s,o,i,r),enFdl=!1}

See the full, unminified code and changelog on Gist/GitHub.

Official Google Analytics 4, Global Site Tag (gtag.js) = 179kB+
Snippet (current version, minified) just 3kB without script tag.

The snippet sends page views, site searches, search queries, scrolls and file downloads directly to Google Analytics 4 (using /g/collect) and stores some metrics in localStorage and sessionStorage in our browser to prevent generating dummy entries by that same user.

Thanks to that, we don’t need to load an official script and the loading speeds of our website will not be affected.

If you need to load the official script (taking a risk that it will be blocked by AdBlockers), you can still do this together with the JavaScript delay approach described here Google Universal Analytics property is shutting down. Here is what you need to know..

Snippet will identify searches on the site based on search queries in URLs like q, s, search, query and keyword. Once detected it sending and event view_search_results (instead page_view) and search_term as even parameters (since 1.07)

In Universal Analytics Google automatically strips search queries from URLs and extracts search terms from them. This is not the case in Google Analytics 4 so you need to send them as even parameters.

The snippet can be easily modified to add any custom search queries (remember to add this into the configuration on Google Analytics Data Streams as well, not only on the snippet).

Previously script fired at the beginning of the page load and its interaction was finished. Since version 1.09 there is a listener added that waits for visitor interaction through scroll. Once a visitor scrolls to (or below) 90% of a page it fires again recording the scroll event and its interaction finishes and the listener is removed.

After some time since the last version, in 1.10, I managed to add tracking to downloaded files.

This happens by adding a click listener to any <a href link on a website that linked file matched with a specified extension (see below). These extensions have been copied from official Google documentation but can be extended easily if you like.

const extensions = ["pdf", "xls", "xlsx", "doc", "docx", "txt", "rtf", "csv", "exe", "key", "pps", "ppt", "pptx", "7z", "pkg", "rar", "gz", "zip", "avi", "mov", "mp4", "mpe", "mpeg", "wmv", "mid", "midi", "mp3", "wav", "wma"];

Additionally, if there is a link with a download attribute (<a href="..." download>), independently if you linking to know extension (from above) or to any other file, click listener will be added as well and tracking will be fired when actions occur.

Minimal Analytics 4 - the setup

Set up your Google Analytics 4 property and Web stream. In Enhanced measurement select Page views, Scrolls, Site Search and File downloads. Copy the above snippet and paste it into your website <head>. Replace G-XXXXXXXXXX with your measurement ID (from Web Stream), and you are ready to go.

Make sure, to paste your code in <head> of the document after the <title> tag.

Despite that snipped weight a tonne less than the official script, still is liable for blocking by various AdBlockers. But there is a solution for that (if your hosting provider, like Netlify, allows you to set Redirects 200). See the masking solution described below.

Minimal Analytics 4 - masking (hiding) requests

My site is hosted on Netlify and they allow, through _redirects file, to set not typical redirect with code 200 (OK), working as a proxy URL.

200 Code informs about the successful processing of the request.

My rule looks as follows

/g/collect https://www.google-analytics.com/g/collect 200

Then I am replacing the following part in the code


With my site address


The call happens on the website from the same address as the main site, so it’s not blocked by AdBlocker. In the background, on the server side (on Netlify CDN in my instance) my URL is converted to an official one and the request is sent together with payload /g/collect?v=2&tid=G... (all after ? is a payload).

There is another option to pass the payload of the URL through a more advanced redirect as explained on Masking (hiding) Google Analytics 4 code

This solution has its downsides as described in my other post Prevent Google Analytics from being blocked by AdBlocker – The Downside.

JavaScript is not something that I am using every day. I used Minimal Analytics snipped by David Künnen as a guide to build mine and it works as expected.

I am quite a beginner in JavaScript and learning when adding new features to it (and others). Luckily, there are others, with greater experience in this field who pick up my work and offer a bit more functions.

The code is free, and you can call it “the base” for further development. Appreciate it if any of you, with much more JavaScript knowledge, could help in its further development.

James Hill used his knowledge to offer another solution for minimal Google Analytics 4 using TypeScript that is worth trying as well.

If you are more technical, and you want to know how this was built and what each element means, read the below technical specification.

Minimal Analytics 4 - technical specification

A bit of technical description of the code.

Page view or Site search tracking is sent to Google together with gathered data (payload) using https://www.google-analytics.com/g/collect URL.

This URL is likely to be blocked by AdBlockers or DuckDuckGo extension to Internet Browser resulting in many visits (in my calculations around 40%) not measured. This is why, despite the downside, a masking solution can help with that.

Payload (data) is added to it in serialised form and contains

  • v=2, Measurement Protocol Version 2 for GA4;
  • tid=G-XXXXXXXXXX, Measurement ID for GA4 or Stream ID;
  • gtm=XXXXXX (removed from minified since version 1.07 as currently not in use) Google Tag Manager (GTM) Hash Info. If the current hit is generated from GTM, it will contain a hash of the current GTM/GTAG config. In one of my tracking, it looks like 2oe3n1.
  • _p=4723978523, another value sent with payload with an unknown purpose. Due to a lack of official documentation, I assumed that it randomly generated a n-digit number. In case it needs to be unique per session it’s stored in browser sessionStorage;
  • sr=1920x1080, Screen Resolution;
  • ul=en, visiting user language;
  • cid=1659736851.1648993429, client ID hold in browser localStorage. Identify new users from returning users. Randomly generated, set of two n-digit numbers;
  • _fv, first view, identifies returning users based on the existence of client ID in localStorage;
  • _s=1, session hits count;
  • dl=https://..., URL of tracked page;
  • dt=Title, Title of the tracked page;
  • dr=https://..., if the user comes to the tracked page from a different page or site, this will hold the referrer URL;
  • sid=1648993317, holds a unique session (page visit) ID and is storing it in browser sessionStorage. Generated based on date function;
  • sct=1, its session engagement factor. Officially shall be set if the user interacts with the website for at least 10 seconds (assume that interaction happened). This value will increase +1 when the user will be visiting other pages on our website in the current session;
  • en=page_view, en=scroll, en=view_search_results or en=file_download, counted event, page_view for views, scroll for scroll below 90% and view_search_results for site searches and file_download for file downloads. This code can be further modified and triggered with different events;
  • epn.percent_scrolled=90, reporting that the user scrolled to 90% of the page or below. Works in conjunction with en=scroll;
  • ep.search_term=microsoft, it’s a tracked search term (what the user typed into the search form)
  • ep.file_extension=pdf, it’s used to identify the extension of the file with file_download event;
  • ep.file_name=filename, it’s used to identify the file name of the downloaded file;
  • ep.link_text=download, it is used to identify the link text on which the user click to download the file;
  • ep.link_url=/downloads/filename.pdf, it’s used to identify the path to the downloaded file. Local files are stripped from the domain name (origin) and external pasted with full link (with a Google limit of 100 characters).
  • _ss=1, count visit as a new session
  • _dbg=1, enable analytics to debug that is recorded on on Google Analytics website, in the debug section.

Based on url and payload data the URL is combined, creating fullurl and in such form is sent.

As you will notice, the request from the script is not sent through the expected solution navigator.sendBeacon(url, data) but more rather (url+"?"+data). Not sure why, but somehow on (url, data) approach something is lost and the page view is not recorded, whereas on the other solution, it is.

There are some commented parts for cleaning sessionStorage, localStorage and displaying the output of URL with payload in the browser console. Uncomment this if you want to analyse how it all works.

Hope this code will provide us with a solution for the following years with Google Analytics 4, in the same manner as David’s minimal analytics provided before with Universal Analytics (GA3).

Privacy and other aspects

This snipped, as opposed to Google’s original script, (almost) not tracking your activity all the time you are on the website. Once the page is loaded, the script is fired up, appropriate data are sent and user interaction is finished. End of story.

Because of that, some reports that rely on the original script on the Google Analytics interface will not show any data or the data will look very strange (like time spent on the website).

The snippet is run (fired) again if you refresh the page or go to a different page on the website. After that, once again, the interaction is finished. You need to remember that.

With version 1.09 there is a listener added that is waiting for the user to scroll to 90% or below and then report that even. After that, the interaction (listener) finishes. The script is not tracking anything else in a the background.

Since 1.10, there is an active listener added to any links to files that can be downloaded from the website. The listener remains active only for this purpose and fire tracking only, when action occurs (user clicks on file to download). No other tracking happens apart from that.

Because of that, it’s not possible to show how long a user has been on one page or his engagement time with it. It’s not a fault, it’s the way this is designed and run (fired).

I have learned about the use of listeners in JavaScript along with version 1.09. This opens for me an ability to learn how to track other aspects like engagement time so hopefully, in the near future, I will be able to implement it as well.

Like David Kuennen mentioned in his minimal analytics for UA:

“You should not use this if you want to use advanced features like tracking AdWords”.

There is no difference with this one. You need to think about what you need and decide what solution is good for you.

For example, once you land on the website, the snippet automatically assumes that you are engaged with it. It’s not working on the principle of waiting 10 or 30 seconds before assuming that the user is engaged or not.

Spam bots generating fake visits rarely use JavaScript hence they will not fire up this snippet. Major of the trackings are for real users. Once I will figure out how to track engagement time accurately, I will rethink the above approach.

Delaying snippet until the user interaction

If you look into my post Implementing JavaScript Delay for Cookie Consent Banner, you can implement it for this solution.

JavaScript Delay waiting by default for user interaction through the movement of the cursor, keypress, scroll or touch before it’s firing it. If a user loads the page but there is no interaction, as a failsafe, the script will fire up after 5 seconds delay (can be adjusted). This can act as a delay to engagement (Engaged Session) if needed.

Apart from that, I don’t see a way (for not) of measuring time (Engagement Time) without constantly (at the beginning) tracking the user and having the script working in the background (listening) until it’s assumed that the user is engaged and time has been measured. This is against the ideology of this approach.

We want to measure how many users visit our website and what parts of the website are most popular. This is the simplest approach for all web enthusiasts, bloggers, and small company websites.

Remember, however, that despite that our tracking ability is simplified, it’s still tracking. For some websites, you need to consider having a relevant policy (GDPR) and consent banner for your users, especially in recent revelations where some countries EU trying to convince you that Google Analytics is illegal and violating GDPR.

Comments and Improvement

Do you have a comment about this minimal analytics snippet? Feel free to drop it in Gist Repository.

The idea of this script being public is that others will help develop it further. If you have a decent knowledge of JavaScript and want to participate in further development, please get in touch. My skills may be limited and I am constantly learning how to improve them. Others’ experience is highly appreciated.


I am aware of the limitations of the current snippet (and my own). If you are requesting a feature to be implemented, don’t be surprised that it will not happen straight away. I need to learn how to do that, this is why. If you know how, please share and we will all make it better.

Don’t act like some silly dudes who are first to complain and last to give feedback to make it better. We are all learning. If you know what you need and you know how to achieve it by implementing some changes in your current work, let’s work together not against each other.

Thank you to all who say a nice word about my work and share it further.

Credits to: