In short: Universal Analytics has shut down, so ga() calls no longer report to a property. In GA4, send the event with gtag() and an event_callback, and add your own timer, because a blocked Google tag leaves the callback unrun and a form waiting for it never submits.
A contact form that sends an analytics event before it submits can stop submitting altogether, with no error in the browser console. That is what this post was first written about: a form handler called ga() with a hitCallback, the callback never ran, and the form never went anywhere. The same trap exists today with gtag() and an event_callback, and the fix is a few lines. Universal Analytics itself is legacy now, so the sections below start with what changed and then fix the form.
What was the original problem, and what has changed since?
The original code sent an event through ga() and submitted the form from the hitCallback. If the callback never ran, nothing submitted the form, and the browser showed no error. In the original case, the old call had been left behind when the tracking code changed.
Two things have moved on since then:
- Universal Analytics is gone. Standard Universal Analytics properties stopped processing hits on 1 July 2023. Universal Analytics 360 properties with a current order had an extension to 1 July 2024, and access to the old data ended on the same day[1]. A ga() call can no longer report to a property, so do not rely on its hitCallback to control whether a form submits.
- The old naming was mixed up. An earlier version of this post called ga() "Classic" and gtag() "Universal". Google's own switching guide treats ga() as the command of analytics.js, the script for Universal Analytics properties, and gtag.js as the script for Google Analytics 4 (GA4) properties[2]. The same guide notes that a gtag.js tag ID can stand for several Google products, including a Universal Analytics property[2], so gtag.js could also point at a Universal Analytics property.
If your site still has ga() calls, they no longer record anything useful. Replace them, as below, rather than keeping them for a property that no longer collects.
How do you send the same event with gtag() and GA4?
GA4 has no category, action and label fields. An event is a name plus parameters: gtag(, placed below the Google tag snippet[5]. For a form that generates a lead, GA4 has a recommended event called generate_lead. It takes lead_source and, if the lead has a monetary value, value together with currency[6]. Category and label are not dedicated GA4 fields, so put anything else you want to keep in a parameter, or in a custom event of your own.
The tag snippet that Google gives you also defines the gtag function itself, on the page, before the external script has loaded[3]. Remember that for the next section.
Before you add this code: it changes what runs when a visitor submits a form on your site. Try it on a staging copy or a test site first, take a backup of the files, and apply it at your own risk. iLogr cannot see your site and accepts no responsibility for changes made to it.
The legacy pattern, for recognition only. Universal Analytics no longer processes these hits:
// Universal Analytics (analytics.js). Retired; recognition only.
ga('send', 'event', 'Lead', 'Submit', 'contact_form', {
hitCallback: function () {
form.submit();
}
});
The GA4 version of the same idea:
gtag('event', 'generate_lead', {
lead_source: 'contact_form',
event_callback: function () {
form.submit();
}
});
Google defines event_callback as a function "called when processing of an event command has completed"[4]. That sentence is the whole promise: the callback runs after the tag has processed the event. If the tag never gets to process it, nothing is promised.
Why can a form still hang when you use gtag()?
Because the callback belongs to the Google tag script, and the script may not be there. A visitor with a content blocker, a firewall that blocks the Google tag domain, or any other setup that stops the script from loading never gets it. The inline part of the snippet still ran, so the page has a working gtag function that only collects commands in dataLayer. A check such as typeof gtag !== 'undefined' passes, the event goes nowhere, the callback never runs, and the form waits for it forever.
Tested on Chrome 155, with Google's tag snippet loaded from googletagmanager.com using a test measurement ID. Requests to Google's collection endpoints were answered locally, so no data reached any property. The handler ran from a button click on a plain HTML form. Each case was run three times and counted as a success when the form's target page was requested within 5 seconds. Three ways of writing the form handler were compared: the plain event_callback, the same with event_timeout: 1000 added[4], and the timer fallback shown in the next section.
| Case | Waits for the callback only | Plus event_timeout: 1000 |
Timer fallback |
|---|---|---|---|
| Google tag not on the page | Submitted, 3 of 3 | Submitted, 3 of 3 | Submitted, 3 of 3 |
| Google tag loads normally | Submitted, 3 of 3 | Submitted, 3 of 3 | Submitted, 3 of 3 |
| Tag script request blocked | Never submitted, 0 of 3 | Never submitted, 0 of 3 | Submitted after about 1.1 seconds, 3 of 3 |
| Tag loads, collection request blocked | Submitted, 3 of 3 | Submitted, 3 of 3 | Submitted, 3 of 3 |
| Consent default set to denied | Submitted, 3 of 3 | Submitted, 3 of 3 | Submitted, 3 of 3 |
Three things stand out. When the tag script was blocked, typeof gtag was still function, so that guard does not protect you. The event_timeout parameter did not help either, which fits its definition as the timeout used for the callback[4]: that logic lives inside the script that was not loaded. And a blocked collection request, or a denied consent default, did not stop the callback, so only script blocking prevented submission in these cases. One browser, one tag version and a default-denied consent state are what was tested, not a consent banner of any particular make.

How do you stop the form waiting for a tag that never answers?
Start your own timer, and make sure the form can only be submitted once, whichever of the two arrives first. Define form as your HTML form element and run this block from the form's submit handler, after preventing the default submission. It assumes a plain form that submits natively; a plugin or AJAX form needs the same idea applied to its own submit step.
var submitted = false;
function submitOnce() {
if (submitted) { return; }
submitted = true;
form.submit();
}
if (typeof gtag === 'function') {
gtag('event', 'generate_lead', {
lead_source: 'contact_form',
event_callback: submitOnce
});
setTimeout(submitOnce, 1000);
} else {
submitOnce();
}
The one-second figure is a trade-off, not a rule. A longer wait gives a slow connection more time to deliver the event and delays every visitor whose tag is blocked by the same amount. In the test above, the fallback submitted after about 1.1 seconds, just over the timer.
If the event is not critical, the safest design is to not wait for it at all: send the event, then submit. Waiting is only worth it when you have a reason to believe the event would otherwise be lost.
Only a default-denied consent state (analytics storage and ad storage) was tested, with no consent banner and no tag manager, so try the timer in your own setup. Google's consent guide sets the default to denied before any measurement command[7].
How do you check that the fix works?
Test the test first: the unfixed form should hang in step 2, or the check proves nothing.
- Load the page with the unfixed handler. Open the browser developer tools and use the Network request blocking tab in the drawer[8].
- Block the Google tag domain (googletagmanager.com), reload and submit the form. The unfixed handler should hang.
- Switch to the handler with the timer. The form should submit after about a second.
- Unblock the domain and submit again. The form should submit straight after the event is processed.
- In GA4, open the Realtime report or DebugView to see events as they arrive[5].
References
Numbers in square brackets in the text link to these sources.
- Google Analytics Help: Google Analytics 4 has replaced Universal Analytics (opens in a new tab).Used for: the dates Universal Analytics stopped processing data and data access ended.
- Google Analytics Help: Tips for switching from analytics.js to gtag.js (legacy) (opens in a new tab).Used for: ga() as the analytics.js command, and what gtag.js is for.
- Google for Developers: Set up the Google tag with gtag.js (opens in a new tab).Used for: the tag snippet, which defines the gtag function inline.
- Google for Developers: Google tag (gtag.js) parameter reference (opens in a new tab).Used for: the definitions of event_callback and event_timeout.
- Google for Developers: Set up events (opens in a new tab).Used for: GA4 event syntax, placement below the snippet, and where to see events.
- Google for Developers: Recommended events (opens in a new tab).Used for: the generate_lead event and its parameters.
- Google for Developers: Set up consent mode on websites (opens in a new tab).Used for: setting the consent default to denied before measurement.
- Chrome for Developers: Network features reference (opens in a new tab).Used for: the Network request blocking tab in the DevTools drawer.
4 Responses
I was having the same issue, but then I switched to Google Tag Manager for managing such events using dataLayer that is better solution than using gtag or ga for sending events.
Yes, GTM is surely a better way but a lot of business / website still out there who uses traditional way of Google Analytics, so for them this guide will help if they face similar issue. Thanks for reading :)
Nice post, thanks Prashant…
I was having the same error, nice chrome extension thank you for sharing… keep up the good work…