About

Cookie, localStorage or sessionStorage — Three Questions Decide It

Why the folder tree went to a cookie and the scroll position to sessionStorage on the same screen

Where to keep a piece of UI state comes down to three questions. Does the server read it? Must the first HTML show it? Should it survive closing the tab? A yes to either of the first two means a cookie, a yes to the third means localStorage, and all no means sessionStorage. After this you can pick one with a reason instead of defaulting to localStorage.

1. Does the server read it? sent with every request 2. Must the first HTML show it? otherwise it flickers (FOUC) 3. Keep it after the tab closes? survive a restart yesyesyesnonono Cookielogin session Cookiedark theme, open folders localStoragepreferences on this device sessionStoragelist scroll position Tokens, secretsHttpOnly Cookie. No tree.

Tokens and secrets skip the tree. They go in an HttpOnly cookie, full stop. More on why in the security part.

Same screen, two answers

We recently made one app remember two things: which sidebar folders are open, and the list's scroll position. My first thought was "both are UI state, put them in the same place." Once I worked through it, the answers were different.

Folder tree → cookie. Open or closed is a single <details open> attribute. If the server knows it, the tree is drawn open from the start. Put it in localStorage and the server can't know, so everything renders closed and JS expands it a moment later. That jump is FOUC (Flash of Unstyled Content: a half-styled page that shows for a split second).

Scroll position → sessionStorage. scrollTop isn't something HTML or CSS can express. Even if the server knew it, JS would still have to apply it. So a cookie buys nothing here, and you pay for it on every image, CSS and API request. It should differ per tab anyway, and nobody gains anything by stealing it.

The fork turned out to be "can HTML attributes or CSS express this state?" If yes, the server can draw it up front, and a cookie wins.

The six axes in one table

Axis Cookie localStorage sessionStorage
Sent to server every request no no
In first render yes no (after JS) no (after JS)
Lifetime set with Max-Age until cleared until the tab closes
Scope domain + path, subdomains possible every tab of the origin origin + one tab
Size about 4KB each about 5MB per origin about 5MB per origin
Hidden from JS HttpOnly no no

Limits vary a little by browser. The numbers are rough figures from MDN.

"Until cleared" lasts longer than you'd think. A state from months ago comes back and someone asks why the page is acting weird. Don't put anything in localStorage that would cause trouble if it came back.

Security: why not keep a JWT in localStorage

Any JS on the page can read Web Storage. One XSS hole and the token walks out. A cookie with HttpOnly can't be read by JS at all, and you can add Secure and SameSite on top. Only cookies can do that, so "JWT in localStorage" is a pattern to avoid.

For something like a scroll position, which is worthless if stolen, this axis doesn't matter.

Things we stepped on

sessionStorage can throw. setItem throws when the quota is full or storage is blocked. We called it inside a requestAnimationFrame callback, so the errors piled up on every scroll. Wrap both reads and writes.

const key = `scroll:contracts:${listId}`   // split the key by screen + target ID

function readScroll() {
  try { return Number(sessionStorage.getItem(key)) || 0 } catch { return 0 }
}
function saveScroll(y) {
  try { sessionStorage.setItem(key, String(y)) } catch {}
}

One key called scroll lets screen A's value leak into screen B. Put the screen name and target ID in the key and that bug can't happen.

"Gone when the tab closes" isn't the whole story either. Reopening a closed tab (Cmd+Shift+T) or duplicating a tab brings sessionStorage along.

On the cookie side, it's easy to forget they ride along with static file requests too. Fifty requests on a page means fifty copies. Stuff UI leftovers into cookies and you'll hit the 4KB limit quickly.

If the data is megabytes or structured, none of the three fit. Use IndexedDB. The Web Storage API is synchronous, so reading or writing a big value blocks the main thread.

If you're sorting out this kind of state in a Rails or Hotwire app and getting stuck, feel free to ask negabaro. We rewrote the same screen a few times before landing on these rules.

Key Points

1

If the server branches on the value, it is a cookie. There is no other option

2

State that HTML attributes or CSS can express (theme, open/closed) goes in a cookie so the server draws it up front

3

Values only JS can restore (scroll position) go to Web Storage. If they should differ per tab, sessionStorage

4

Tokens go in HttpOnly cookies. Wrap Web Storage access in try/catch and scope keys by screen + ID

Use Cases

Settings like dark mode or a collapsed sidebar that must not flash on first paint Restoring scroll position on list → detail → back
← Pretext — A Pure JS Layout Library That Measures Text Height Without DOM event.target vs currentTarget — The Bug That Only Fired on the Icon Stroke →