We use cookies to enhance your experience on the site
CodeWorlds

Web Storage - localStorage and sessionStorage

Dennis Nedry is sitting in the Jurassic Park control room, switching the map view to satellite for the twentieth time today. "I am not clicking the same thing after every page refresh," he grumbles. And he has a point: not every piece of information deserves a round trip to the server. The preferred map view, the color theme, the last sector somebody looked at, the half-filled incident report form - all of that can happily stay inside the visitor's browser. JavaScript ships two built-in mechanisms for exactly this job: localStorage and sessionStorage. Both hold key-value pairs on the client side, both expose an identical API, and both work without a single line of server code. They really differ in one thing only - how long they remember.

localStorage vs sessionStorage

| Feature | localStorage | sessionStorage | |---------|--------------|----------------| | Persistence | Survives closing and reopening the browser | Cleared the moment the tab or window closes | | Scope | Shared across every tab and window of the same origin | Isolated to the current tab | | Storage limit | ~5-10 MB per origin | ~5-10 MB per tab | | Use case | User preferences and settings | Temporary session data, form state |

One sentence here is worth remembering for life: data in localStorage persists permanently until somebody deliberately deletes it, while data in sessionStorage disappears once the tab is closed. That is the single real difference between the two, and there is no second one hiding behind it.

While we are at it, let us straighten out three misunderstandings, because they circulate with remarkable stubbornness. First, sessionStorage does not have a larger storage limit - both spaces hold roughly the same amount, somewhere around 5-10 MB. Second, both stores keep strings and nothing else; neither of them is able to save an object, so a claim along the lines of "localStorage takes only text while sessionStorage takes objects" is simply untrue. Third, both work in every modern browser - localStorage is not a Chrome-only invention, and sessionStorage is not its more portable stand-in. You choose between them on exactly one basis: whether the data is supposed to outlive the closing of the tab.

Basic Operations

The entire API fits into five names:

setItem
,
getItem
,
removeItem
,
clear
and
key
, plus the
length
property. The same five serve both stores, so you learn them once and the matter is settled.
setItem
takes a key and a value,
getItem
returns the value saved under a key - or
null
when that key was never there. That is
null
, not
undefined
, and a surprising number of conditions stumble over this small detail.
removeItem
deletes a single entry,
clear
sweeps out everything belonging to the origin, and
key(i)
lets you walk the store index by index whenever you want to see what is actually sitting inside it.

1// setItem - save data
2localStorage.setItem('parkTheme', 'dark');
3localStorage.setItem('language', 'en');
4sessionStorage.setItem('currentSector', 'B-4');
5
6// getItem - read data
7const theme = localStorage.getItem('parkTheme'); // "dark"
8const missing = localStorage.getItem('nothere'); // null
9
10// removeItem - delete a single key
11localStorage.removeItem('parkTheme');
12
13// clear - delete all data
14localStorage.clear();
15
16// length and key() - iterating over the store
17console.log(localStorage.length); // how many keys are in the store
18for (let i = 0; i < localStorage.length; i++) {
19  const key = localStorage.key(i);
20  console.log(key, localStorage.getItem(key));
21}

Look at the third line.

sessionStorage
is driven exactly like
localStorage
- only the object name in front of the dot changes. The sector number written into sessionStorage will vanish together with the tab, which makes it a perfect home for working data nobody wants to see again tomorrow morning.
clear()
is the treacherous one: it erases absolutely everything your origin has ever saved, including entries produced by completely unrelated parts of the application. In a real project it is safer to delete keys by name, or to keep them under a shared prefix and clear only that prefix. The loop with
key()
is precisely the tool for that second approach.

Storing Complex Data with JSON

The store accepts strings only and has no intention of negotiating. Hand it an object and it will not raise an error - it quietly turns the object into text using the same coercion you know from gluing an object onto a string. The result is always identical and always useless: the string

[object Object]
lands in the store, and every property is lost for good. This is one of those mistakes that does not crash the application straight away; it waits patiently until somebody tries to read the saved data back.

1// WARNING - the object will be turned into "[object Object]"
2const dino = { name: 'Rex', species: 'T-Rex' };
3localStorage.setItem('dino', dino);
4console.log(localStorage.getItem('dino')); // "[object Object]"

The answer is JSON - a text format capable of describing an object or an array faithfully.

JSON.stringify
converts a structure into a string before the write,
JSON.parse
rebuilds it after the read. This pair always travels together, and it is what allows localStorage to hold a whole set of visitor preferences, a list of recent incidents, or a nested object of feeding reminders and security alerts. Nesting is no obstacle here - JSON copes with an object inside an object just as comfortably as with a flat list of keys. The only thing worth remembering is that JSON knows nothing about functions, dates or
undefined
: a date comes back from the store as an ordinary string and has to be pushed through
new Date()
by hand, a function disappears along the way without any warning, and a property set to
undefined
simply never shows up in the saved text. For everyday user settings not one of those limits gets in the way.

1// Storing an object
2const userPrefs = {
3  theme: 'dark',
4  fontSize: 16,
5  language: 'en',
6  notifications: { security: true, feeding: true }
7};
8
9localStorage.setItem('userPreferences', JSON.stringify(userPrefs));
10
11// Reading it back
12const storedPrefs = JSON.parse(localStorage.getItem('userPreferences'));
13console.log(storedPrefs.theme); // "dark"
14
15// Storing an array
16const recentIncidents = ['INC-001', 'INC-002', 'INC-003'];
17localStorage.setItem('recentIncidents', JSON.stringify(recentIncidents));
18
19const incidents = JSON.parse(localStorage.getItem('recentIncidents') || '[]');

The expression that saves an object is worth taking apart into four pieces, because that is exactly the order in which you assemble it: first the store object

localStorage
, then the call
.setItem(
, then the key together with the opening of the conversion
'key', JSON.stringify(
, and at the very end the object with the two closing brackets
object))
. Those two brackets are not a typo - the first one closes
JSON.stringify
, the second one closes
setItem
. Reading follows the same route in reverse:
JSON.parse
wraps
localStorage.getItem('key')
and hands you a finished object. The last line shows one more habit worth stealing -
|| '[]'
substitutes an empty array when the key is absent, so that
JSON.parse
never receives
null
.

Safe Reading and Writing

Nothing in the park control room works flawlessly, and browser storage is no exception.

JSON.parse
blows up with an exception if the key holds text that nobody ever put through
stringify
- and it takes very little for that to happen: a user poking around in the developer tools by hand, or an older version of the application that saved data in a different shape.
setItem
can throw as well: in Safari private mode the write is sometimes blocked, and crossing the storage limit gives you a quota exceeded exception. That is why production code wraps both calls in
try...catch
and returns a sensible fallback instead of letting the application fall over.

1// Reading that survives a missing key and broken JSON
2function getStoredData(key, defaultValue = null) {
3  try {
4    const item = localStorage.getItem(key);
5    return item ? JSON.parse(item) : defaultValue;
6  } catch (error) {
7    console.error(`Parsing key ${key} failed:`, error);
8    return defaultValue;
9  }
10}
11
12const settings = getStoredData('userSettings', { theme: 'light', language: 'en' });
13
14// Safe writing - Safari private mode, storage out of space
15function safeSetItem(key, value) {
16  try {
17    localStorage.setItem(key, value);
18    return true;
19  } catch (error) {
20    console.error('Writing to localStorage failed:', error);
21    return false;
22  }
23}

Both functions share one philosophy: a storage failure must never stop the application.

getStoredData
always returns something - either the real data or the default value you supplied yourself as the second argument.
safeSetItem
returns
true
or
false
, so the calling code gets to decide whether to show a message or simply pretend that nothing happened. You will notice this is exactly the same pattern applied when working with the network: we assume the operation may fail, and we keep a plan B ready. Jurassic Park taught everybody that systems without a plan B end badly.

The UserPreferences Class

Calls to

setItem
and
getItem
scattered across the whole codebase quickly turn into a problem: nobody remembers which key holds what, and default values get duplicated in five different places. The answer is a single class that keeps the entire set of preferences under one key in the store and looks after the defaults itself. The rest of the application then sees only
get
and
set
, and the fact that
JSON.stringify
and
JSON.parse
run underneath stops being any of its business. Pay attention to the
get
method - it checks whether the key is present with the
in
operator rather than a plain truthiness test, so a stored
false
is never mistaken for a missing setting.

1class UserPreferences {
2  constructor() {
3    this.storageKey = 'jurassicParkPrefs';
4    this.defaults = {
5      theme: 'light',
6      language: 'en',
7      alertSound: true,
8      mapZoom: 2,
9      favoriteSection: 'overview'
10    };
11  }
12
13  get(key) {
14    const stored = localStorage.getItem(this.storageKey);
15    const prefs = stored ? JSON.parse(stored) : {};
16    return key in prefs ? prefs[key] : this.defaults[key];
17  }
18
19  set(key, value) {
20    const stored = localStorage.getItem(this.storageKey);
21    const prefs = stored ? JSON.parse(stored) : {};
22    prefs[key] = value;
23    localStorage.setItem(this.storageKey, JSON.stringify(prefs));
24  }
25
26  setMultiple(updates) {
27    const stored = localStorage.getItem(this.storageKey);
28    const prefs = stored ? JSON.parse(stored) : {};
29    Object.assign(prefs, updates);
30    localStorage.setItem(this.storageKey, JSON.stringify(prefs));
31  }
32
33  reset() {
34    localStorage.removeItem(this.storageKey);
35  }
36
37  getAll() {
38    const stored = localStorage.getItem(this.storageKey);
39    const prefs = stored ? JSON.parse(stored) : {};
40    return { ...this.defaults, ...prefs };
41  }
42}
43
44const prefs = new UserPreferences();
45prefs.set('theme', 'dark');
46prefs.set('alertSound', false);
47console.log(prefs.get('theme')); // "dark"
48console.log(prefs.getAll()); // All preferences together with the defaults

getAll
demonstrates one more trick: spreading
this.defaults
ahead of the stored preferences means the user always receives a complete set of settings, even when only a single one of them actually sits in the store. The method names are of course your own decision. In the practice exercise you will build a
PreferencesManager
for saving and reading user preferences in Jurassic Park, and its methods are named
save()
,
load()
,
remove()
and
clear()
. The pattern is identical:
save()
takes a preferences object, pushes it through
JSON.stringify
and passes it to
setItem
,
load()
travels the same road the other way round through
getItem
and
JSON.parse
,
remove()
deletes one key, and
clear()
tidies away the whole set.

DataCache with TTL (Time To Live)

Preferences are not the only data worth remembering. A dinosaur card fetched from an API does not change every second, so questioning the server every time the same profile is opened wastes both time and bandwidth. The answer is a cache with a TTL, meaning the time to live of an entry. We save the value together with an

expiresAt
stamp calculated as
Date.now() + ttlMs
, and on read we compare that stamp with the current time. An expired entry gets deleted and
null
comes back, exactly as though it had never existed. The class below takes the store in its constructor, so the very same code serves localStorage and sessionStorage - all you do is pass a different object.

1class DataCache {
2  constructor(storage = localStorage, defaultTtlMs = 5 * 60 * 1000) {
3    this.storage = storage;
4    this.defaultTtlMs = defaultTtlMs;
5  }
6
7  set(key, value, ttlMs = this.defaultTtlMs) {
8    const item = {
9      value,
10      expiresAt: Date.now() + ttlMs
11    };
12    this.storage.setItem(`cache:${key}`, JSON.stringify(item));
13  }
14
15  get(key) {
16    const stored = this.storage.getItem(`cache:${key}`);
17    if (!stored) return null;
18
19    const item = JSON.parse(stored);
20
21    if (Date.now() > item.expiresAt) {
22      this.storage.removeItem(`cache:${key}`);
23      return null; // Entry expired
24    }
25
26    return item.value;
27  }
28
29  getOrFetch(key, fetchFn, ttlMs) {
30    const cached = this.get(key);
31    if (cached !== null) return Promise.resolve(cached);
32
33    return fetchFn().then(value => {
34      this.set(key, value, ttlMs);
35      return value;
36    });
37  }
38
39  clear(prefix) {
40    const keysToRemove = [];
41    for (let i = 0; i < this.storage.length; i++) {
42      const key = this.storage.key(i);
43      if (key && key.startsWith(`cache:${prefix || ''}`)) {
44        keysToRemove.push(key);
45      }
46    }
47    keysToRemove.forEach(key => this.storage.removeItem(key));
48  }
49}
50
51// In practice
52const cache = new DataCache(localStorage, 10 * 60 * 1000); // TTL of 10 minutes
53
54async function getDinosaurData(id) {
55  return cache.getOrFetch(
56    `dino:${id}`,
57    () => fetch(`/api/dinosaurs/${id}`).then(r => r.json()),
58    5 * 60 * 1000  // 5 minutes
59  );
60}

The most interesting piece here is

getOrFetch
, because it packs the whole scheme into one call: check the cache, and if it is empty - fetch, save and return. The calling code never needs to know where the data came from. It is also worth noticing why the
cache:
prefix is glued onto every key. Thanks to it, the
clear
method can wipe out cache entries only, leaving the user preferences stored right next to them untouched - this is precisely the safer alternative to a global
localStorage.clear()
that was mentioned earlier. Notice as well that the keys to remove are first gathered into an array and deleted only afterwards: removing them while iterating over
length
would shift the indexes, and some entries would be skipped.

Security Warnings

IMPORTANT - never keep sensitive data in Web Storage:

  • passwords or their hashes,
  • authentication tokens (use httpOnly cookies instead),
  • credit card numbers,
  • personal data (GDPR, CCPA),
  • session tokens.

The reason is simple and unforgiving: Web Storage is reachable by any JavaScript running on the page. There are no protective flags there, no encryption, no access control. A single script injected through an XSS attack - your own or one dragged in by a third-party library - is enough to ship the whole content of the store to the attacker's server in one line. Dennis Nedry proved in the park that the weakest point of a system is never the fence, but the convenient side gate left open by somebody in a hurry. A color theme, the last page visited or the state of a form can safely sit there. An access token for the fence control system - never.

Summary

Web Storage is a simple and convenient client-side store:

  1. localStorage - the data persists permanently and is shared between all tabs of the same origin
  2. sessionStorage - the data belongs to a single tab and disappears once that tab is closed
  3. JSON.stringify and JSON.parse - mandatory when saving objects and arrays, because the store speaks strings only
  4. UserPreferences - the class pattern that keeps a whole set of settings under one key and guards the defaults
  5. DataCache with TTL - a cache of API responses that deletes expired entries by itself and saves needless requests
  6. try...catch - writing and reading can fail (private mode, full store, broken JSON), so always keep a plan B
  7. Security - no passwords, no tokens, no personal data, because every script on the page can reach them
Go to CodeWorlds