Efficiently Counting App Impressions in Shopify Apps Using navigator.sendBeacon

When developing a Shopify app, tracking how often your app’s theme extension is displayed on merchants’ stores is essential. However, continuously logging each impression can become resource-intensive, especially when aiming to minimize the impact on the user experience and server resources.

In this blog post, we’ll explore a solution that uses navigator.sendBeacon to efficiently count app impressions while optimizing resource usage. This approach allows you to log impressions in batches, sending data only when necessary—such as when a user closes the page—thereby reducing the load on your servers.

Problem: Efficiently Counting Impressions

The goal is to count each time the theme app extension appears on a merchant's store. However, logging each impression immediately upon occurrence can strain server resources, especially under high-traffic conditions.

Solution: Batch Logging with navigator.sendBeacon

To address this challenge, we inject a script into the online store with the theme extension. This script handles impression logging as follows:

  1. Local Storage Logging: Each impression is stored locally in the visitor’s browser.
  2. Batch Sending: Impressions are sent in batches to the server when certain conditions are met, such as the user closing the page or at regular intervals.
  3. CORS and OPTIONS: Proper CORS headers are necessary to allow cross-origin requests from the Shopify store to your server.
  4. Vite Configuration: Use remix-utils in Vite to manage CORS and other functionalities in your Remix-based Shopify app.

Let’s dive into the implementation.

Step 1: Handling CORS in the API Route

To receive pings from the Shopify store, we need to ensure our API route supports CORS (Cross-Origin Resource Sharing). Here’s how we set it up in Remix:

import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { cors } from "remix-utils/cors";
import { addPing } from "../utils/pings/ping-buffer";
import { checkAndTriggerAppPingLimit } from "~/utils/pings/check-app-pings";
import { AppPing } from "@prisma/client";
import { randomUUID } from "crypto";

// Handle CORS for OPTIONS requests
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const response = json({ status: 200 });
  return await cors(request, response, {
    origin: /\.myshopify\.com$/,
    methods: ['OPTIONS'],
    allowedHeaders: ['Content-Type'],
    credentials: true,
  });
};

// Handle POST requests and log impressions
export const action = async ({ request }: ActionFunctionArgs) => {
  if (request.method === "OPTIONS") {
    const response = json({ status: 200 });
    return await cors(request, response, {
      origin: /\.myshopify\.com$/,
      methods: ['POST', 'OPTIONS'],
      allowedHeaders: ['Content-Type'],
      credentials: true,
    });
  }

  try {
    const { impressions } = await request.json();

    if (!Array.isArray(impressions) || impressions.length === 0) {
      return json({ error: 'Invalid impressions data' }, { status: 400 });
    }

    const data = impressions.map((impression: any) => ({
      id: randomUUID(),
      shopId: impression.shop,
      timestamp: new Date(impression.timestamp),
    }));
    
    data.forEach((ping: AppPing) => {
      addPing(ping);
    });

    const uniqueShops = [...new Set(data.map(d => d.shopId))];
    await Promise.all(uniqueShops.map(shopId => {
      console.log(`Checking app pings for shop ${shopId}`);
      return checkAndTriggerAppPingLimit(shopId);
    }));

    const response = json({ message: "success" });
    return await cors(request, response, {
      origin: /\.myshopify\.com$/,
      methods: ['POST', 'OPTIONS'],
      allowedHeaders: ['Content-Type'],
      credentials: true,
    });
  } catch (error) {
    console.error('Error logging impressions:', error);
    return json({ error: 'Internal server error' }, { status: 500 });
  }
};

Step 2: Injecting the Script in the Theme Extension

To track impressions, the following script is injected into the merchant’s store via the theme extension. This script logs impressions in local storage and sends them using navigator.sendBeacon when the visitor closes the page:

(function() {
  if (typeof Shopify.designMode === 'undefined' || !Shopify.designMode) {
    const impressionsKey = 'appImpressions';
    const BATCH_INTERVAL = 60000; // 60 seconds

    function loadImpressions() {
      const impressions = localStorage.getItem(impressionsKey);
      return impressions ? JSON.parse(impressions) : [];
    }

    function saveImpressions(impressions) {
      localStorage.setItem(impressionsKey, JSON.stringify(impressions));
    }

    function sendBatch(impressions, useBeacon = false) {
      if (impressions.length === 0) return;

      const url = 'https://your-server-url.com/api/impressions';
      const data = JSON.stringify({ impressions });

      if (useBeacon && navigator.sendBeacon) {
        console.log("Using beacon to send data");
        const blob = new Blob([data], { type: 'application/json' });
        navigator.sendBeacon(url, blob);
      } else {
        console.log("Using fetch to send data");
        fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: data
        })
        .then(response => response.json())
        .then(data => console.log('Batch logged:', data))
        .catch(error => console.error('Error logging batch:', error));
      }

      saveImpressions([]);
    }

    function addImpression(shopDomain) {
      const impressions = loadImpressions();
      impressions.push({
        shop: shopDomain,
        timestamp: new Date().toISOString()
      });
      saveImpressions(impressions);
    }

    function debounce(func, delay) {
      let timeoutId;
      return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => func.apply(this, args), delay);
      };
    }

    const debouncedSendBatch = debounce(() => {
      const impressions = loadImpressions();
      sendBatch(impressions, true);
    }, BATCH_INTERVAL);

    document.addEventListener('DOMContentLoaded', function() {
      const shopDomain = '{{ shop.domain }}';
      addImpression(shopDomain);
      debouncedSendBatch();
      setInterval(debouncedSendBatch, BATCH_INTERVAL);
    });

    //This is where sendBeacon is called when the user closes the page

    window.addEventListener('pagehide', function() {
      const impressions = loadImpressions();
      sendBatch(impressions, true);
    });
  }
})();

Step 3: Vite Configuration for remix-utils

You need to use remix-utils to be able to handle CORS requests. remix-utils needs to be added to SSR array in the vite config file so that it can be bundled.

import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig, type UserConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

if (process.env.HOST && (!process.env.SHOPIFY_APP_URL || process.env.SHOPIFY_APP_URL === process.env.HOST)) {
  process.env.SHOPIFY_APP_URL = process.env.HOST;
  delete process.env.HOST;
}

const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost").hostname;

let hmrConfig;
if (host === "localhost") {
  hmrConfig = {
    protocol: "ws",
    host: "localhost",
    port: 64999,
    clientPort: 64999,
  };
} else {
  hmrConfig = {
    protocol: "wss",
    host: host,
    port: parseInt(process.env.FRONTEND_PORT!) || 8002,
    clientPort: 443,
  };
}

export default defineConfig({
  server: {
    port: Number(process.env.PORT || 3000),
    hmr: hmrConfig,
    fs: {
      allow: ["app", "node_modules", "public"],
    },
  },
  plugins: [
    remix({
      ignoredRouteFiles: ["**/.*"],
    }),
    tsconfigPaths(),
  ],
  build: {
    assetsInlineLimit: 0,
  },
  ssr: {
    noExternal: [
      "/remix-utils/",
    ],
  },
}) satisfies UserConfig;

Conclusion

By leveraging navigator.sendBeacon for batching and sending app impressions, you can significantly reduce the resource load on your server while ensuring accurate logging. This approach, combined with proper CORS handling and an optimized Vite configuration, makes it easier to track and manage theme extension impressions in your Shopify app.

This method not only improves performance but also ensures reliable delivery of impression data, providing you with more accurate insights into your app’s usage on merchant stores.

Read more