More consistent form listeners in Google Tag Manager
Sick of the Google Tag Manager built-in form listener not working? This hosted form listener waits for the thank you instead of guessing at the click.
The problem
There are many ways to build a form on the web, which makes tracking successful submissions hard. Each of these breaks a naive listener:
- The form prevents the default submit event and posts through JavaScript instead.
- The form redirects to a thank you page before the tag can fire.
- JavaScript calls
form.submit()directly, which dispatches no submit event at all. - The form dispatches submit events even when it is invalid, so failures get counted as conversions.
- A site search box sits on the same page as the form you actually care about.
- The site is a single page app, so the form is not in the DOM when your tag fires.
If you need help with something similar to this blog post, then get in touch through my contact page.
Get in touchThe solution: wait for the thank you, not the click
The trick is to stop trying to decide whether a submission worked at the moment someone clicks the button. At that point you cannot know.
So split it in two:
- When any form is submitted, grab the field values and stash them in
sessionStorage. - When the site itself says the submission succeeded — a thank you page loads, a thank you message appears, the router changes route — fire the event using the stashed values.
The website’s own success signal becomes the conversion. If the submission fails, that signal never comes and nothing fires, which quietly solves the invalid-submission and site-search problems. Because the captured data lives in sessionStorage, it also survives a full page navigation.
I have packaged this up as a script you can point a Custom HTML tag at:
https://cdn.curtiswarner.work/mega-form-listener.js
It is plain ES5 in an IIFE, has no dependencies, and does not care whether the form was in the DOM when it loaded — it listens on document in the capture phase rather than binding to individual buttons.
I cover the caveats in the closing comments.
Installing it
Create a new Custom HTML tag in Google Tag Manager, paste the snippet below in, and fire it on all DOM Ready events.
Fire it once per page load and no more. Do not add a virtual page view trigger for single page apps: the listener watches for route changes itself, and re-firing the tag would inject a second copy of the script and leave you with two instances both reporting the same submission.
Set the config object before the script tag. The script auto-initialises the moment it loads if it finds window.MegaFormListenerConfig, so declaring the config first avoids a race.
<script>
window.MegaFormListenerConfig = {
thankYouUrl: '/thank-you',
thankYouSelector: '.form-success-message'
};
</script>
<script src="https://cdn.curtiswarner.work/mega-form-listener.js"></script>
Use the same tag, with the same config, on every page. The page holding the form and the page confirming the submission are often not the same page, and both ends need the listener.
Trigger
Configuration
| Option | Default | What it does |
|---|---|---|
thankYouUrl |
null |
String or RegExp tested against the full window.location.href. A string is a case-insensitive “contains” match. |
thankYouSelector |
null |
CSS selector for an element that only exists, and is visible, after a successful submission. |
dataLayerName |
'dataLayer' |
Change it if your container uses a renamed dataLayer. |
eventName |
'form_submit' |
The event value pushed to the dataLayer. |
storageKey |
'mega_form_listener_pending' |
The sessionStorage key holding the pending submission. |
persistenceTimeout |
3600000 |
How long, in milliseconds, a pending submission stays valid. One hour by default. |
debug |
false |
Logs every capture and every success check to the console. Worth turning on while you set it up. |
Only thankYouUrl and thankYouSelector really matter, and you need at least one of them. With neither set, the listener captures submissions and then waits forever for a signal that never arrives.
Set both where you can. They are evaluated as an OR, so whichever arrives first wins the race, and a form that sometimes redirects and sometimes swaps in a message is covered either way.
Scenario 1: a thank you message appears in place
The most common modern case. The form posts over AJAX and replaces itself with a message. Nothing navigates, so there is nothing for a page view trigger to catch.
window.MegaFormListenerConfig = {
thankYouSelector: '#signup-thanks'
};
When thankYouSelector is set, the listener attaches a MutationObserver to document.body watching child nodes across the whole subtree, plus the style, class and hidden attributes. That covers both patterns: a message injected fresh, and a message that was always in the DOM and just gets un-hidden. Mutations are debounced by 200ms so a chatty framework re-render does not cause a storm of checks.
The element also has to be visible, not merely present. The check is element.offsetParent !== null, so a display: none placeholder sitting in the markup from page load will not fire anything.
Two things worth noticing in that demo.
The search box submits without producing anything. Under the old approach you would have needed to exclude it by selector or method. Here it excludes itself: search never produces a thank you message, so it never counts.
And none of the inputs have a name attribute, yet the dataLayer keys still read Full name and Email address. That is deliberate.
Field names come from labels
For each field the listener works down a priority list:
- The text of a
<label for="...">pointing at the field’s id. - The text of a
<label>wrapping the field. - The field’s
nameattribute. Unnamed field 1,Unnamed field 2, and so on.
Label text wins because it is what the person filling the form actually read, and because form builders love to emit names like field_7. Nested inputs are stripped out of the label text before it is used, so a checkbox wrapped in its own label gets a clean key.
Radio and checkbox groups collapse into one key, with the checked values joined by a comma. Submit, reset, button, image and password inputs are skipped entirely.
Scenario 2: the form redirects to a thank you page
The old-school case, and the one that breaks tags most reliably: the browser starts unloading the page the instant the form submits, so anything you fire on click is in a race with navigation that it will sometimes lose.
window.MegaFormListenerConfig = {
thankYouUrl: '/thank-you'
};
Here the two halves come apart across a page load. On the form page the listener captures the fields and writes them to sessionStorage. On the thank you page a completely fresh copy of the script starts up, reads that pending submission back, checks the URL during init(), matches, and fires immediately. The form element is long gone by then and it does not matter.
Pass a RegExp instead of a string when you need to be precise, for example /\/thank-you\/?$/ so that /thank-you-page-builder does not match.
Programmatic submits
That demo has a second button which calls form.submit() from JavaScript. This is worth calling out on its own, because form.submit() does not dispatch a submit event. It also skips the browser’s built-in validation. A listener built on addEventListener('submit') — including GTM’s own — sees precisely nothing, which is why some forms appear to be untrackable.
The script handles this by patching HTMLFormElement.prototype.submit once, notifying every registered instance, and then calling through to the original. The demo shows both paths side by side: the naive listener records the normal submit and misses the programmatic one, while the form data is captured either way.
Scenario 3: single page app route change
In a single page app the “thank you page” is a route, not a document. Nothing loads, nothing unloads, and your DOM Ready trigger fired once, ages ago, before the form existed.
window.MegaFormListenerConfig = {
thankYouUrl: /step=thank-you/
};
The listener patches history.pushState and history.replaceState and listens for popstate, re-checking the URL after each. It guards against double-patching, so a tag that fires again on a virtual page view will not wrap the same function twice.
Because the submit listener is bound to document in the capture phase rather than to the forms themselves, forms that mount long after the tag fired are still covered. There is nothing to re-attach on route change.
If you need help with something similar to this blog post, then get in touch through my contact page.
Get in touchWhat lands in the dataLayer
One event, only on success:
{
event: 'form_submit',
form_id: 'newsletter',
inputs: {
'Full name': 'Ada Lovelace',
'Email address': '[email protected]',
'Email me occasionally': 'on'
},
firstEmailFound: '[email protected]',
submissionDigest: '2567345861',
valid: true,
timestamp: 1785000000000
}
form_id is the form’s id, falling back to its name, then to unknown_form.
firstEmailFound is the first value in any field that looks like an email address. Handy when the email field is not called email, or when someone pastes their address into a free-text box.
submissionDigest is a hash of the visible field values, sorted by key. The same person submitting the same form twice produces the same digest, which makes it a cheap deduplication key downstream. Hidden fields are left out of it, so a rotating CSRF token or timestamp will not change the hash between attempts.
valid is always true. The event only exists because a success signal arrived, so there is no invalid variant to handle.
In GTM, trigger on a Custom Event matching form_submit, then pull whatever you need out with Data Layer Variables — form_id, firstEmailFound, submissionDigest, or a specific key like inputs.Email address.
Closing comments
Everything except passwords ends up in the dataLayer
inputs carries the value of every non-password field. That will routinely include names, email addresses, phone numbers and free-text notes, and the dataLayer is readable by every tag in your container and by anyone with the console open.
Do not forward that object wholesale to an analytics platform. Pick out the specific keys you have a reason to collect, and check that doing so is consistent with your privacy policy and whatever consent you have.
One instance per page
Instances register themselves in a global list, and each one listens to every submit on the document. Two instances on the same page will both capture every form and both push an event on either success signal, so you get duplicate conversions. That is why each demo above is in its own iframe rather than all sharing this page.
The easiest way to end up with two is to let the tag fire more than once, which is why the trigger matters. The other way is deliberately running two configs for two different forms — don’t. Give them one config with both thankYouUrl and thankYouSelector set instead.
A lingering thank you element causes false conversions
The success check runs on any DOM mutation while a submission is pending. If your thank you message stays on screen indefinitely, a later submission of some other form — the search box, a newsletter widget in the footer — will be captured, immediately see the still-visible message, and fire a conversion carrying that other form’s values. You can reproduce it in the first demo.
Point thankYouSelector at something that is removed or hidden once acknowledged, or that only ever appears on a dedicated confirmation view.
The email match is permissive on purpose
Excluding valid email addresses costs you more than counting a few false positives, so firstEmailFound uses a loose pattern. It will happily match something inside a longer sentence. Treat it as a strong hint, not as validation.
sessionStorage is per-tab
A pending submission does not follow the visitor into a new tab, and it is dropped after an hour by default. That is the right trade-off for a form fill, but if your flow involves a payment provider opening a new tab and returning, the pending submission will not be there when they come back.
Hash routers are not patched
Only pushState, replaceState and popstate are watched. A router that only ever writes location.hash will not trip the URL check — add a thankYouSelector for those.
Forms are still weird
Some forms out there will not work with this, and probably never will. Turn on debug: true, watch what gets captured and what the success check sees, and adjust from there. Good luck!