Avoiding the Traps of Meta Pixel Event Tracking in In-app Browsers

Last Updated on

Background

I created a landing page with code like this:

<a
  href="https://buy.stripe.com/xxx"
  target="_blank"
  onclick="
    fbq('track', 'InitiateCheckout');
    gtag('event', 'begin_checkout');
  "
>
  Buy Now
</a>

In theory, it's supposed to work perfectly: when a user clicks on a checkout link - it opens Stripe in a new tab, while at the same time, a "click" event is being triggered and the page in the background sends events to Meta Pixel and Google Analytics.

Diagram of how it works in a normal browser

When testing both on a laptop and mobile - it worked perfectly; the page opens up and events are being registered.

Then I started running Meta Ad Campaigns on Facebook and Instagram. They were performing poorly; I didn't see any InitiateCheckout events. Then I got a sale from the ads, but still no InitiateCheckout events, that's when I got suspicious.

I realized that I was testing my website in normal browsers (Chrome), but Facebook and Instagram mobile apps open websites in their in-app browser (so that they can track more stuff I assume). And that in-app browser doesn't support multiple tabs, it's just one tab. So when a user clicks on a target="_blank" link - instead of opening it in a new tab - it will be opened in the current one. And so our asynchronous events don't have a chance to be submitted, because the original page context is being destroyed and replaced with a new page almost instantly, not leaving enough time for fbq and gtag to communicate the locally captured event to the Meta and Google servers.

Diagram of how it fails to work in an in-app browser

Potential Solutions

Using async/await or callbacks

TL;DR

We could've used callbacks or promises to know when events have been submitted, but neither gtag nor fbq supports this.

Diagram of how callbacks could be used to solve the problem

Pros:

Cons:

Details

As mentioned in Google tag (gtag.js) parameter reference, there's an event_callback parameter we could use to do something like this:

<button
  onclick="gtag('event', 'begin_checkout', {
    'event_callback': function() {
      // WARNING: this will be fired before the request to GA completes
      window.location.href = 'https://buy.stripe.com/xxx';
    }
  })"
>
  Buy Now
</button>

However, I tested this by setting my network speed to very slow in developer tools, and I could see the event_callback being fired way before the actual request to the Google Analytics server was completed. Meaning that this can't be used as a reliable solution.

And as per Is there a callback functionality for Facebook conversion events similar to Google Analytics? question, there's no callback functionality for fbq at all.

Also, both fbq and gtag functions return undefined instead of a Promise, so we can't await fbq(...).

Even if it was possible - the user would have to wait for both requests to finish, which could add a significant delay after clicking "Buy Now", depending on the user's network conditions.

Add a timeout

TL;DR

Delay the redirect so that the event has a higher chance of being submitted; bad UX.

Diagram of how timeout solution works

Pros:

Cons:

Details

We could work around the problem of instant context destruction by delaying the redirect, which will allow some more time for analytic events to reach the destination servers. It can look somewhat like this:

<button
  onclick="
    fbq('track', 'InitiateCheckout');
    gtag('event', 'begin_checkout');
    setTimeout(function() {
      window.location.href = 'https://buy.stripe.com/xxx';
    }, 500); // NOTE: lower time - better UX; higher time - more reliable
  "
>
  Buy Now
</button>

One would have to carefully balance user experience VS reliability, because the more we wait before the redirect - the higher the chances for the tracking requests to be completed before the redirect. But waiting for too long will increase user misery and they might leave your website before it finishes loading, which will negatively affect your conversions. Keep in mind, that having incomplete pixel analytics data will also negatively affect your conversions because Meta won't be able to optimize ads as effectively.

A potential workaround for higher timeout values (over 1 second) would be to add a loader and display it while waiting for the redirect. This way, the user will know that something is happening and is less likely to leave the page.

Diagram of how timeout solution fails

Conversions API

TL;DR

Use Meta's Conversions API to send events from the backend.

Diagram of how Conversions API solution works with Express.js

Pros:

Cons:

Details

This solution involves sending analytics events to Meta using Conversions API.

We could have the following handler in our NodeJS server using the @rivercode/facebook-conversion-api package:

function handler(request, response) {
  try {
    const FBConversionAPI = new FacebookConversionAPI(
      'accessToken',
      'etc.' /* see docs: https://www.npmjs.com/package/@rivercode/facebook-conversion-api#initiate-facebook-conversion-api */
    );
    FBConversionAPI.sendEvent('InitiateCheckout', 'http://some.url');
  } catch (e) {
    console.error(e);
  }

  return response.redirect(302, 'https://buy.stripe.com/xxx');
}

The trick is that we're using a "fire-and-forget" technique for our analytics events, meaning that we don't have to wait for the response, or even for the connection to be established with Meta servers; we redirect the user to checkout straight away and the server continues sending the HTTP request with our analytics data in the background. This can be achieved with an Express.js server, for example.

However, if you're using Next.js - it can be more complicated because it's deployed to Vercel as Serverless or Edge Functions, and it doesn't seem to allow running async functions in the background after the response has been served to the end user. There's a @rivercode/facebook-conversion-api-nextjs package that might help you address this problem.

I have a static HTML landing page and didn't want to use React, but I wanted to deploy on Vercel, so I had to use Serverless Functions. And since they don't support background execution of async functions - I had to create a patch for the @rivercode/facebook-conversion-api package so that it returns a promise, and await that promise before redirecting the user. This is not an ideal solution as users will have to wait for the analytics request to complete, but Meta usually replies quickly, so it's not a big issue in my case.

When patching @rivercode/facebook-conversion-api you need to patch compiled JS files, and type-definition d.ts files if you're using TypeScript as well:

diff --git a/node_modules/@rivercode/facebook-conversion-api/dist/index.d.ts b/node_modules/@rivercode/facebook-conversion-api/dist/index.d.ts
index 3c33be4..737d357 100644
--- a/node_modules/@rivercode/facebook-conversion-api/dist/index.d.ts
+++ b/node_modules/@rivercode/facebook-conversion-api/dist/index.d.ts
@@ -41,6 +41,6 @@ declare class FacebookConversionAPI {
         currency?: string;
     }, eventData?: {
         eventId?: string;
-    }): void;
+    }): Promise<unknown>;
 }
 export default FacebookConversionAPI;
diff --git a/node_modules/@rivercode/facebook-conversion-api/dist/index.js b/node_modules/@rivercode/facebook-conversion-api/dist/index.js
index 3027a4d..d2470c6 100644
--- a/node_modules/@rivercode/facebook-conversion-api/dist/index.js
+++ b/node_modules/@rivercode/facebook-conversion-api/dist/index.js
@@ -65,10 +65,12 @@ class FacebookConversionAPI {
         const eventRequest = (new bizSdk.EventRequest(this.accessToken, this.pixelId))
             .setEvents([__classPrivateFieldGet(this, _FacebookConversionAPI_instances, "m", _FacebookConversionAPI_getEventData).call(this, eventName, sourceUrl, purchaseData, eventData)]);
         this.contents = [];
-        eventRequest.execute().then((response) => response, (error) => error);
+
         if (this.debug) {
             console.log(`Event Request: ${JSON.stringify(eventRequest)}\n`);
         }
+
+        return eventRequest.execute();
     }
 }
 _FacebookConversionAPI_instances = new WeakSet(), _FacebookConversionAPI_getEventData = function _FacebookConversionAPI_getEventData(eventName, sourceUrl, purchaseData, eventData) {
diff --git a/node_modules/@rivercode/facebook-conversion-api/src/index.ts b/node_modules/@rivercode/facebook-conversion-api/src/index.ts

Refer to the patch-package documentation for details on how to set it up.

Also, when setting up the Conversions API, it's recommended to add event IDs, so that Meta can do merging/deduplication of your server events and client events.

So my final solution looks like this:

Diagram of how Conversions API solution works with Next.js

api/landing.ts:

import FacebookConversionAPI from '@rivercode/facebook-conversion-api';
import type { VercelResponse } from '@vercel/node';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { v4 as uuidv4 } from 'uuid';

export default async function handler(
  request: VercelRequest,
  response: VercelResponse
) {
  const pageViewEventId = uuidv4();
  const checkoutInitEventId = uuidv4();
  try {
    const FBConversionAPI = new FacebookConversionAPI();

    FBConversionAPI.addProduct('my-product', 1);

    await FBConversionAPI.sendEvent(
      'ViewContent',
      'http://some.url',
      {},
      {
        eventId: pageViewEventId,
      }
    );
  } catch (e) {
    console.error(e);
  }

  return response.send(
    readFileSync(resolve(__dirname, '../public/index.html'), 'utf-8')
      .replaceAll('_PAGE_VIEW_EVENT_ID_', pageViewEventId)
      .replaceAll('_CHECKOUT_INIT_EVENT_ID_', checkoutInitEventId)
  );
}

api/checkout.ts:

import FacebookConversionAPI from '@rivercode/facebook-conversion-api';
import type { VercelRequest, VercelResponse } from '@vercel/node';

export default async function handler(
  request: VercelRequest,
  response: VercelResponse
) {
  try {
    const url = new URL(request.url, 'https://my.website/');

    const checkoutInitEventId = url.searchParams.get('checkoutInitEventId');

    const FBConversionAPI = new FacebookConversionAPI();
    FBConversionAPI.addProduct('my-product', 1);
    await FBConversionAPI.sendEvent(
      'InitiateCheckout',
      'http://another.url',
      {},
      {
        eventId: checkoutInitEventId,
      }
    );
  } catch (e) {
    console.error(e);
  }

  return response.redirect(302, 'https://buy.stripe.com/xxx');
}

public/index.html:

<head>
  <script>
    fbq(
      'track',
      'PageView',
      {},
      {
        eventID: '_PAGE_VIEW_EVENT_ID_',
      }
    );
  </script>
</head>
<body>
  <noscript
    ><img
      height="1"
      width="1"
      style="display: none"
      src="https://www.facebook.com/tr?id=1234567890&ev=PageView&noscript=1&eid=_PAGE_VIEW_EVENT_ID_"
  /></noscript>
  <a
    onclick="fbq('track', 'InitiateCheckout', {}, { eventID: '_CHECKOUT_INIT_EVENT_ID_' }); gtag('event', 'begin_checkout')"
    href="/api/checkout?checkoutInitEventId=_CHECKOUT_INIT_EVENT_ID_"
    target="_blank"
    rel="noopener"
  >
    Buy Now
  </a>
</body>

And here's my implementation of the FacebookConversionAPI setup:

const IP_HEADER_NAME = 'x-real-ip'; // see https://vercel.com/docs/edge-network/headers#x-real-ip

const ip =
  typeof request.headers[IP_HEADER_NAME] === 'string'
    ? request.headers[IP_HEADER_NAME]
    : request.headers[IP_HEADER_NAME][0] ?? '';
const userAgent = request.headers['user-agent'] ?? '';

const url = new URL(request.url, 'https://my.website/');

const fbclid = url.searchParams.get('fbclid');

// see https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc/
const fbc = fbclid || parseCookies(request)._fbc; // `parseCookies` copied from https://stackoverflow.com/a/3409200/4536543
const fbp = parseCookies(request)._fbp;

const FBConversionAPI = new FacebookConversionAPI(
  'accessToken',
  'pixelId',
  null, // emails
  null, // phones
  ip,
  userAgent,
  fbp,
  fbc,
  true // debug; defaults to false
);

Reflections

Setting up the Conversions API with Express.js would have been easier than with Next.js, and also would have resulted in a better user experience in my case. One should consider looking into getting a VPS from DigitalOcean for as little as 4 USD per month and running an Express.js instance there, perhaps using the pm2 npm package to help deal with unhandled server errors, etc. You can use my referral link to get 200 USD credit; see the Referral Program for more details. I personally have been using DO quite a lot when learning web development and server management. Later, I switched to Heroku as it's simple yet flexible. I like pipelines and loved their free tier, but now it'll cost at least 5 USD per month, and 7 USD per month if you want to have SSL with a custom domain; see Heroku Pricing.

My Conversions API setup seems quite primitive and might not be the best. There might be other issues that I missed or am unaware of. This makes me think that perhaps I would be better off using some existing platform like Shopify that has much more experience in the field and many engineers working on it. They might be more likely to get it right and use customer feedback to make it better. Perhaps I should look into that more moving forward.

Conclusion

When running Meta ads with in-app placements, the target=_blank + onclick approach doesn't work. Promise/callback isn't an option. Timeout can be used with care as a band-aid. Conversions API seems like the best approach overall. For Conversions API Express.js is better than Next.js, and Shopify might be better than both.


Please, share (Tweet) this article if you found it helpful or entertaining!