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.
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
If the server branches on the value, it is a cookie. There is no other option
State that HTML attributes or CSS can express (theme, open/closed) goes in a cookie so the server draws it up front
Values only JS can restore (scroll position) go to Web Storage. If they should differ per tab, sessionStorage
Tokens go in HttpOnly cookies. Wrap Web Storage access in try/catch and scope keys by screen + ID