Skip to content
Back

Local Storage, Session Storage, and Cookies

A practical guide to browser storage, its trade-offs, and the security boundaries that matter in production

Browser storage looks simple until it becomes part of an authentication flow, a multi-tab experience, or an application that must survive schema changes. The API is rarely the difficult part. The real decision is who needs the data, how long it should live, and which security boundary it crosses.


One terminology note: browsers do not expose a standard API named cookieStorage. There are traditional cookies through document.cookie and the newer asynchronous Cookie Store API through cookieStore. They manage the same underlying cookies, but neither behaves like Web Storage.


Overview

PropertylocalStoragesessionStorageCookies
Typical lifetimeUntil explicitly cleared or evictedCurrent tab's page sessionSession-based or until their expiry date
Sent to serverNoNoYes, on matching requests
JavaScript accessYes, synchronouslyYes, synchronouslyYes, unless marked HttpOnly
Best suited forPersistent, non-sensitive preferencesTemporary state that should survive reloadsServer-managed sessions and small server-readable values

The practical distinction is that Web Storage belongs to the client, while cookies participate in the HTTP request lifecycle. None should be treated as a trusted database.



localStorage

localStorage persists data for an origin across page reloads and browser restarts. It is useful for small, non-sensitive preferences such as a theme, a dismissed notice, or an unfinished local draft.


const preferences = {
  theme: "dark",
  density: "compact",
}

localStorage.setItem("preferences:v1", JSON.stringify(preferences))

const stored = localStorage.getItem("preferences:v1")
const parsed = stored ? JSON.parse(stored) : null

The convenience comes with limits. Values are strings, access is synchronous, and every read or write blocks the main thread. Capacity and eviction behavior also vary by browser. I treat it as a small persistence mechanism, not a database.


Any JavaScript running on the origin can read it. That includes malicious code introduced through an XSS vulnerability, so I do not store session tokens, passwords, or sensitive personal data in localStorage. Long-lived bearer tokens are especially risky because stealing the value is enough to reuse it elsewhere.


I also version stored keys and validate parsed data. Deployed applications change while old browser data remains:


type Preferences = {
  theme: "light" | "dark"
}

function readPreferences(): Preferences | null {
  try {
    const value = JSON.parse(localStorage.getItem("preferences:v1") ?? "null")

    if (value?.theme === "light" || value?.theme === "dark") {
      return value
    }
  } catch {
    // Corrupt or manually edited storage should not break the application.
  }

  return null
}


sessionStorage

sessionStorage has a similar string-based API, but its lifetime is tied to a page session. It survives reloads in the same tab and is normally cleared when that tab or window closes. Separate tabs get separate storage.


That makes it a good fit for temporary UI state: a multi-step form, a return URL, or filters that should survive an accidental refresh without becoming a permanent preference.


sessionStorage.setItem(
  "checkout:draft",
  JSON.stringify({ step: 2, deliveryMethod: "pickup" })
)

It is not a security boundary. Scripts on the page can still read it, and users can duplicate or restore tabs in ways that make lifecycle assumptions less obvious. I use sessionStorage because its product lifetime matches the data, not because it is safer than localStorage.



Cookies are different because the browser can attach them to HTTP requests. That makes them appropriate when the server needs the value, especially for server-managed sessions.


Security-sensitive cookies should usually be created by the server:


Set-Cookie: session=opaque-value; Path=/; HttpOnly; Secure; SameSite=Lax

HttpOnly prevents JavaScript from reading the cookie, Secure restricts it to HTTPS, and SameSite helps limit cross-site request forgery. These attributes reduce risk, but they do not replace output escaping, a Content Security Policy, CSRF analysis, or proper session expiration and rotation.


Traditional document.cookie is synchronous and awkward to parse. The Cookie Store API provides an asynchronous interface where supported:


const preference = await cookieStore.get("theme")

await cookieStore.set({
  name: "theme",
  value: "dark",
  path: "/",
  sameSite: "lax",
})

Client-side code still cannot read an HttpOnly cookie through cookieStore. That restriction is the point. I also avoid putting general application state in cookies: they are small, domain and path rules are subtle, and cookies included with requests add network overhead every time.



The Decision I Use

My default choices are:

  • Use in-memory state when the data only needs to live while the current page is open.
  • Use sessionStorage when temporary state should survive reloads in one tab.
  • Use localStorage for small, non-sensitive preferences that should persist across visits.
  • Use cookies when the server must receive the value, particularly for an opaque session identifier in a secure, HTTP-only cookie.
  • Use IndexedDB instead when the application needs significant data, structured records, transactions, or offline behavior.

The key principle is to store the least data for the shortest useful lifetime. Browser storage is controlled by the user, can be cleared or modified, and should never be treated as an authoritative source of truth. Validate values when reading them, handle storage failures, and keep server-side authorization independent of anything the client can edit.


Choosing storage is ultimately an architecture decision, not an API preference. Start with the data's lifetime and trust level; the correct browser primitive usually follows.