Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Why using localStorage directly is a bad idea

If you are working on web services for some time you probably remember, or at least heard about, The First Browser War. We are extremely lucky that this scramble between Internet Explorer and Netscape turned into a great race for better, faster, more unified web experience. That said, we're still facing a lot of inconsistency or not trivial edge cases while working with so-called browser APIs.

Some of them are excellent like Service Worker. Despite the complexity of production ready Service Worker, I haven’t come across any inconsistency in browser implementations since I started working on Progressive Web Apps. Well, of course under the condition browser has implemented it. Some APIs are cumbersome like IndexedDB and it blows my mind every time I’m using it how such API was accepted, but I’m digressing right now. The point it unlike those two APIs of a significant size we have also many small APIs which are also taking a burden off developers’ shoulders providing us with a set of great features. One of them is, mentioned in the title, localStorage. In my perfect bubble localStorage always works and I can rely on it unless you are using Opera Mini. The reality is a little bit different, reality assumes Private Browsing and Privacy Content Settings. I’ve learned that the hard way, from error tracking tool.

Everything here also applies to sessionStorage.

QuotaExceededError: Dom exception 22: An attempt was made to add something to storage that exceeded the quota

This rather unusual error is thrown by Safari and has nothing to do with space you are using or which is left on device. Safari, when Private Browsing is enabled (Cmd+Shift+N), doesn’t allow accessing localStorage and it takes us by surprise. This will return true in Safari (also while Private Browsing):

"localStorage" in window // => true

Local storage works perfectly fine in Chrome in Incognito mode and in Firefox Private Window. Data is kept only until you quit the browser.

Uncaught DOMException: Failed to read the 'localStorage' property from 'Window': Access is denied for this document

This error is thrown by Chrome when Content Settings prevents from setting any data. The error message is fair although checking whether localStorage exists in window won’t take us far.

"localStorage" in window // => true

TypeError: Cannot read property 'getItem' of null

The problem itself looks simple, localStorage is null. The root of it is still bothering me. There were two reasons why this captured my attention. The first one is that null represents a missing reference. If localStorage wouldn’t have been implemented, an error should be TypeError: Cannot read property 'getItem' of undefined (like in IE 7) as we’re trying to access not initialized property of window object. The other reason why this error seems strange is that it’s taking place on Android, on latest versions of Chrome. And as you know by now, Chrome tends to alert us about problems with accessing localStorage in much different way. I’ve done some digging and haven’t found anything useful, only some rookie mistakes when setting up a web view. Users’ IPs were different, without any pattern and much repetition, so it can be anything like some crawler, “privacy browser” app or RSS reader preview. I’ll appreciate a comment if you know what it might be.

"localStorage" in window // => true

This is also not enough, localStorage is set to null, but it is set.

SecurityError: The operation is insecure

This error is also thrown is Safari when a user is blocking all cookies in Safari's security settings. It looks like cookies and localStorage share the same settings, and now just accessing the localStorage object causes the error.

"localStorage" in window // => true

Solution

I’ve mentioned that using localStorage directly can be a bad idea and I hope you agree that above problems are a proof of that. Yes, you could wrap each call in a try…catch block but I doubt that this is what you want to do. Certainly, I’m not going to do this. If you are using localStorage in one or two places in your code and you are not using it for critical use cases like storing secure token you could go away with try…catches. Maybe letting a user know that he or she should somehow enable localStorage for the best experience.

My goal was simple, to enable users to sign up or sign in even when localStorage or sessionStorage are not available, whatever the reason is.

If we want to provide a wrapper we start with a reliable way to determine whether storage is supported or not:

function isSupported(getStorage) {
  try {
    const key = "__some_random_key_you_are_not_going_to_use__";
    getStorage().setItem(key, key);
    getStorage().removeItem(key);
    return true;
  } catch (e) {
    return false;
  }
}

isSupported(() => localStorage); // => true | false

In case or any problem with storage, exception is thrown and we are sure we should use an alternative solution. Simple implementation can look like this:

/* ISC License (ISC). Copyright 2017 Michal Zalecki */

export function storageFactory(getStorage: () => Storage): Storage {
  let inMemoryStorage: { [key: string]: string } = {};

  function isSupported() {
    try {
      const testKey = "__some_random_key_you_are_not_going_to_use__";
      getStorage().setItem(testKey, testKey);
      getStorage().removeItem(testKey);
      return true;
    } catch (e) {
      return false;
    }
  }

  function clear(): void {
    if (isSupported()) {
      getStorage().clear();
    } else {
      inMemoryStorage = {};
    }
  }

  function getItem(name: string): string | null {
    if (isSupported()) {
      return getStorage().getItem(name);
    }
    if (inMemoryStorage.hasOwnProperty(name)) {
      return inMemoryStorage[name];
    }
    return null;
  }

  function key(index: number): string | null {
    if (isSupported()) {
      return getStorage().key(index);
    } else {
      return Object.keys(inMemoryStorage)[index] || null;
    }
  }

  function removeItem(name: string): void {
    if (isSupported()) {
      getStorage().removeItem(name);
    } else {
      delete inMemoryStorage[name];
    }
  }

  function setItem(name: string, value: string): void {
    if (isSupported()) {
      getStorage().setItem(name, value);
    } else {
      inMemoryStorage[name] = String(value); // not everyone uses TypeScript
    }
  }

  function length(): number {
    if (isSupported()) {
      return getStorage().length;
    } else {
      return Object.keys(inMemoryStorage).length;
    }
  }

  return {
    getItem,
    setItem,
    removeItem,
    clear,
    key,
    get length() {
      return length();
    },
  };
}

The above code is now a storage-factory library you can easily import in your project.

import storageFactory from "./storageFactory";

export const localStore = storageFactory(() => localStorage);
export const sessionStore = storageFactory(() => sessionStorage);

To provide more sophisticated functionality like support for in operator we would have to clutter code with a bunch of ifs keeping methods and stored values in the same object. We could also use Proxy which slightly defeats the purpose when we are trying to provide solution working possibly in all our clients’ browsers, but it’s up to you. This implementation relays on variable decelerated in a closure, so all information is lost after reloading. Depending on your use case maybe you need to fallback to the server and store required information in session on your backend.

I think it makes sense for us to consider wrapping native APIs in our own modules keeping possibility the same API. Neither you nor your co-workers have to relearn it each time. This way you can benefit from latest and greatest browser features but in the same time have the possibility to escape the limitations and make unit testing easier.

It may be tempting to use one of plenty packages available on npm, but it comes at a cost of additional bytes send to the user and you probably don't need that. I can recommend localstorage-memory for unit testing purposes (localStorage isn't available in NodeJS).

Photo by Nicolas Cool on Unsplash.