Shooting ourselves in the foot with localStorage

All front-end developers love localStorage — after all, you can bury data in it without any databases or servers. But from localStorage you can shoot your leg perfectly – today I will tell you about 6 built-in machine guns:

  1. Key collisions

  2. Changing the data schema

  3. Read and write circuit desynchronization

  4. Errors setItem

  5. Reading localStorage in SSR

  6. Lack of isolation between users

I got tired of being afraid and pushing these issues in every project, so I created a library banditstashwhich gently but persistently protects you from these problems. But first things first.

Key collisions

Storage keys are global on origin, and if two different types of data are stored with one key, nothing good will happen. Okay, in one code base we can somehow make keys unique. The fun begins when two applications live on one host (they are made by different teams). And also landing (thanks for the question, it was made by a freelance schoolboy). Are you 100% sure that none of your NPM packages are naughty with a key like state? (No, not 100%).

We don't need such problems, so let's immediately give all the keys in the application a unique prefix – no setItem('bannerShown')A setItem('myapp:bannerShown'). Well, it wouldn't hurt to force unique keys within the application – for example, to force all keys to be described in one file.

Changing the data format

Then it gets a little more complicated. The product manager brought Junior Petya a task – to show a banner with unique offer just once to emphasize the uniqueness. No sooner said than done!

const key = 'myfood:bannerShown'
const bannerVisible = !localStorage.getItem(key)
// все, в следующий раз не покажем
localStorage.setItem(key, 'yes')

Conversion drops, conditions change – the banner needs to be shown three times so that the user can change his mind. No problem:

const count = Number(localStorage.getItem(key) || 0)
const isVisible = count < 3
localStorage.setItem(key, String(count + 1))

The code compiled, the tests passed, but in reality the banner still doesn't show because Number('yes') -> NaNA NaN < 3 == false. localStorage pulled out its typical trick – I call it “blast from the past”.

Storage seems like it's part of our app, but it's actually more like external service, and also unreliable – there could be anything there. Maybe June Vanya put something in everyone's storage 2 years ago, then deleted the code, and the value lay around. Or maybe a cat ran across the user's keyboard and poked around in the storage. In general, you shouldn't trust localstorage.

How do we deal with unreliable data? We we validate. For example, if we put a number there, we will check exactly that we read the same number:

const value = localStorage.getItem(key)
if (!value) return 0
const num = Number(value)
if (Number.isNaN(num)) return 0
return num

Note that we are doing manual serialization, which is quite boring. It is worth delegating this task to a standard tool (for example, JSON) and do not forget to wrap the parsing in try / catch.

Circuit desynchronization

Same old story, only in profile – desynchronization of types when writing and reading. If work with localStorage is spread across the code, it is easy to put one data in one place…

storage.setItem(keys.banner, { lastShown: Date.now() })

And then in a completely different place try to read others:

storage.getItem<{ count: number }>(keys.banner).count > 3

Luckily, there are two great solutions here. First, you can wrap a couple key + type in the helper is a nice API, in which the client code does not need to know about any keys:

const storage = <T>(key) => ({
  get: (): T | null => {...},
  set: (value: T) => {...},
});
const bannerStorage = storage<{ showCount: number }>('banner')

Secondly, in TS you can declare a global localStorage scheme and force the type through a generic wrapper. At the same time, TS helps us keep keys unique – if there is a collision inside the application, an error will pop up.

interface TypedStorage {
  banner: { showCount: number }
}
function getItem<K extends keyof TypedStorage>(key: K): TypedStorage[K]
function setItem<K extends keyof TypedStorage>(key: K, v: TypedStorage[K]): void

In both cases, direct access to localStorage should be prohibited by the linter so that the machine helps not to screw up.

We don't catch setItem errors

But enough about types. Let's calmly pull setItem with confidence that it will work:

function onClose() {
  // ну что плохого может произойти, верно?
  storage.setItem('promo', { closed: Date.now() })
  setPromoVisible(false)
}

But surprise setItem it might throw an error, if there is no space in the storage,setPromoVisible will never work, and the banner will stop closing. This is not a common case, but you can open the console right now and see that it is possible:

for (let i = 0; i < 2000; i++) {
  localStorage.setItem(i, '1'.repeat(10000))
  // рано или поздно полетят эксепшены
}

No TS will help here — it does not analyze the exception flow, and it is very easy to forget the wrapper in the client code. So you need to wrap try / catch on setItem on the helper layer so that nothing leaks. What to do if you run out of space? There is no good answer: either clear the storage with all the sweet data, or accept the fact that we will not put anything in the storage in this browser anymore.

What if there is no localStorage?

It seems like we've put try/catch everywhere, but there's only one left. fatal flawloсalStorage may not exist at all (at all, localStorage is not defined), and if you're not careful, it won't just be one handler that crashes, but the entire app. And usually not because you aim for IE6, and because you accidentally read the storage on SSR in nodejs.

If you keep this risk in mind, it's fairly easy to avoid it – or screw it up. try / catch wider so they can catch it ReferenceErroror check the existence of storage before accessing it (typeof localStorage !== 'undefined' && localStorage). As usual, you can return any fallback or null.

Since we are talking about SSR, let's remember that when we read one value in SSR, and another is pulled from the real storage on the client, it turns out so-so: either everything breaks (hello React, hello hydration mismatch), or the interface jumps ugly (banner disappeared / appeared). We will not open this topic today.

Data leakage

We can work with localStorage only when the user is on our site. What if I logged out? Let's say the storage was cleared by clicking the “logout” button. But what if I clicked “logout from all sessions” from another device? We definitely won't be able to reach all the storages, and if another user logs in in the same browser, they will be in for a surprise – someone else's banners, settings, drafts, and all that. You can aggressively clear the storage upon login, but this will not add security (I can disable JS and view the storage as much as I want), and it will worsen the UX – with any logout due to a timeout, all local settings are lost.

Let's make 2 conclusions:

  1. IN localStorage You shouldn't put sensitive data there because we don't control access to it. Nothing will help you with this except thinking with your head.

  2. User data (or other switchable entities – companies, baskets, etc.) should be isolated – not for security (there is none), but for convenience. An elegant solution: add userId to the prefix in addition to the application id. That is, the keys are not just bannerand not even myfood:bannerand the whole myfood:ab3ab890:banner. Hooray, the state does not leak between users, and several people can use the application from one device.

How banditstash can help you

If you nervous, like me, in each of your projects a fat wrapper grows around localStorage, which adds a prefix to keys, types and validates data, catches all errors, and does not explode in SSR. I got tired of dragging these wrappers between projects, and I collected all the useful stuff in a small library — banditStash.

type BannerState = { showCount: number };
// типизированное хранилище
const bannerStash = banditStash<BannerState>({
  // можно обернуть sessionStorage или любой объект с совместимым интерфейсом
  storage: typeof localStorage !== 'undefined' ? localStorage : undefined,
  // префикс для ключей
  scope: 'myfood'
  // все данные из стораджа обязательно нужно провалидировать
  parse: (raw) => {
    if (raw instanceof Object && 'showCount' in raw && typeof raw.showCount === 'number') {
      // удобнее использовать библиотеку для валидации вроде superstruct
      return { showCount: raw.showCount };
    }
    // или кидаем ошибку
    fail();
  },
  // если нет значения по ключу или не прошла валидация
  fallback: () => ({ showCount: 0 }),
  // не JSON-сериализуемые типы нужно явно преобразовать в POJO
  // сейчас такой проблемы нет
  prepare: (data) => data,
});

The wrapper has a classic interface getItem / setItemto make it easier to migrate from bare localStorage. But you can create a cursor to work with one field:

const welcomeBannerStash = bannerStash.singleton('welcome');

const welcomeState = welcomeBanner.get();
welcomeBanner.set({ showCount: welcomeState.showCount + 1 });

In addition to the basic API, there is a layered version: we make a “blank” and build several repositories on its basis:

// хранилище
const appStorage = makeBanditStash(localStorage)
  // с префиксом для всех ключей
  .use(scope('app'))
  // json-сериализацией
  .format(json())
  // ловим эксепшены setItem
  .use(safeSet())

// типизированные интерфейсы
const bannerStorage = appStorage.format<number | null>({
  parse: raw => typeof raw === 'number' ? raw : null,
})

type Theme="dark" | 'light';
const themeStorage = appStorage.format<Theme>({
  parse: raw => raw === 'dark' || raw === 'light' ? raw : fail(),
}).use(safeGet((): Theme => 'light')).singleton('theme');

If some of the issues don't bother you (for example, you want to explicitly catch setItem errors) – no problem, don't include this plugin.

The library is very small – less than 500 bytes. There is no reason not to try it in your project!


Today I told you about 6 problems of localStorage and the library banditStash, which helps to work with storage conveniently and safely: validates data, catches errors and helps with serialization. But there are still a few things to keep in mind:

  1. Putting sensitive data in localStorage is not safe

  2. The storage may run out of space and setItem will stop working.

  3. Storage is an unreliable storage, you shouldn't tie critical business processes to it.

As usual, the best gift for me is your stars on GitHub and successful implementations. Waiting for your feedback!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *