← All posts

Stop storing impressions in localStorage

Christian Mathiesen

Christian Mathiesen

Cofounder

We've all been there as frontend engineers. You want to show a user a tooltip, modal announcement, or some other UI element, but you don't want to show it to them every time they visit your site.

You want to show it to them once, and then never again. So what do you do? You store a cookie or something in localStorage, and then check for it every time the user visits your site.

In React, you would do something like this:

import React, { useState, useEffect } from 'react';

const App = () => {
  const [showTooltip, setShowTooltip] = useState(false);

  useEffect(() => {
    const hasSeenTooltip = localStorage.getItem('hasSeenTooltip');
    if (!hasSeenTooltip) {
      setShowTooltip(true);
      localStorage.setItem('hasSeenTooltip', true);
    }
  }, []);

  return (
    <div>
      {showTooltip && <Tooltip />}
      <RestOfApp />
    </div>
  );
};

Mission accomplished! What could go wrong? But then your PM pings you later that night on Slack and asks you why they are still seeing the tooltip when they opened up the website on their home computer.

This is when you try to explain that localStorage is scoped to the browser, not the user. So if a user opens up your site in Chrome and then opens it up in Firefox, they will see the tooltip again.

Okay, that's not too bad, right? A user sees the same tooltip twice, big whoop. But what about the 5 other tooltips you have on your site? And the announcement about your new cool feature? Now they have to see all of those again too.

In addition to that, localStorage comes with a few other problems:

  1. Limited Data Storage: localStorage can only store string data, which severely restricts its usability. Storing anything more complex than a simple string becomes cumbersome and involves convoluted workarounds of endless serialization and deserialization.
  2. Synchronous Operations: localStorage operates synchronously, meaning each operation runs one at a time. This can significantly slow down the runtime of complex applications, making it unfavorable for efficient performance.
  3. Data Size Limitations: Across all major browsers, localStorage imposes a size limit of approximately 5MB. This restriction poses a significant challenge for developers working on data-intensive applications or those requiring offline functionality. Even though your application only uses a limited amount of data, third party libraries may silently be using up your quota.
  4. Lack of Data Protection: localStorage lacks any form of data protection. Any JavaScript code on your page can access it freely, raising serious security concerns. This disregard for data privacy has become a prominent issue and is particularly worrisome in recent years.

How to actually do impression tracking

The solution is to use a service that is scoped to the user, not the browser. To implement this, you can build a simple key-value store API around your database, and then call it from your frontend. This way, you can store a key-value pair for each user, and then check for it every time they visit your site.

This would look something like this in React:

import React, { useState, useEffect } from 'react';

const App = () => {
  const [showTooltip, setShowTooltip] = useState(false);

  useEffect(() => {
    const fetchImpression = async () => {
      const hasSeenTooltip = await fetch('/api/userImpressions?key=hasSeenTooltip');
      if (!hasSeenTooltip) {
        setShowTooltip(true);
        await fetch('/api/userImpressions', { method: 'POST' });
      }
    };

    fetchImpression();
  }, []);

  return (
    <div>
      {showTooltip && <Tooltip />}
      <RestOfApp />
    </div>
  );
};

If you're using Node.js with Express and MongoDB, your API implementation would look something like this:

const express = require('express');

const app = express();

app.get('/api/userImpressions', async (req, res) => {
  const { key } = req.query;
  const user = await User.findOne({ _id: req.user._id });
  res.json(user.impressions[key]);
});

app.post('/api/userImpressions', async (req, res) => {
  const { key } = req.body;
  const user = await User.findOne({ _id: req.user._id });
  user.impressions[key] = true;
  await user.save();
  res.sendStatus(200);
});

This is a very simple implementation, but it gets the job done. You can now store any key-value pair for each user, and check for it every time they visit your site. This is a much better solution than using localStorage because it is scoped to the user, not the browser.

Going beyond impression tracking

With any homegrown solution, things will start getting complicated fast. What if you want to show a tooltip to a user once every 6 months, but you want to reset that timer if they click on a button? Or say you're building a SaaS product and you want to show an announcement, but only to the account admin?

Beyond the simple binary flags of "seen" vs "not seen" there's also more nuanced progress you may want to track. Imagine for instance an onboarding checklist. Now, you're no longer trying to track a single item, but a whole sequence of actions that get checked off depending on your business logic.

Debugging also becomes harder. For instance, to understand why a user is not seeing a certain experience, you'll soon find yourself rolling up dashboards and scripts to help manage the data.

The above situation a common pitfall we all fall into. For instance, many companies start out building their own feature flag management platform from scratch in a similar fashion as the above example. Things once again become more complex as your system evolves, and for that same reason many companies eventually mange to feature flag management platforms such as Launch Darkly or Posthog.

Product Adoption as a Service

If you don't feel like rolling your own API and SDK to solve this problem, we built Frigade to handle all of this for you. Frigade comes with all the API and UI components you need to properly track user impressions and show the right UI elements to the right users at the right time.

What will you build? Get started today.