EngineerGENERALSCALE-SPECIFICCONTESTEDILLUSTRATIVE

The Persistence Ladder

Anonymous only? One device? Survive a reload? Sync across devices? Logged in? Each answer is a rung: in memory, browser storage, server memory, database. Every rung exists to satisfy a requirement the rung below cannot, and costs something the rung below did not.

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

Once the cart must survive, where should it live — and how do you choose a rung by requirement rather than by how serious the project feels?

The situation

The survival question was answered yes. Now the choices multiply: localStorage, a session on the server, Redis, Postgres, "just put it in the user record". Each tutorial does it differently and each one calls its way the standard way. You pick the one whose tutorial you understood best.

The reflex

Choose by familiarity or by ambition. The tutorial you finished used a server session, so the cart goes in a server session; or the store is going to be big, so the cart goes in the fastest store you have heard of. Either way, the rung is chosen before the requirement it serves is written down.

Why it stalls

A server-session cart is chosen for a store with no login and no second device. The requirement was "survive a reload"; the rung delivers "survive a reload, on one server, until the session expires, invisibly to any other server" — more machinery and a new failure (the second server) for no requirement gained.

What the reflex produces — and fails to produce
  • A server-session cart is chosen for a store with no login and no second device. The requirement was "survive a reload"; the rung delivers "survive a reload, on one server, until the session expires, invisibly to any other server" — more machinery and a new failure (the second server) for no requirement gained.
  • The fast store is chosen because the cart "must be fast". Nothing was measured; the cart holds a handful of items and is read a few times per visit. The rung was chosen to answer a scale question nobody asked, and the store now runs a service whose failure mode nobody has thought through.
  • The cart is put inside the user record, which works until the first anonymous shopper — who has no user record — tries to add an item. The rung assumed login; the requirement did not.
  • Because the rung was chosen by familiarity, nobody can say what would move it. "When do we move the cart to the database?" gets "when we need to", which is a date without a reason.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Ask the survival questions in order, and stop at the first "no": Is the shopper anonymous only? Is it one device? Must it survive a reload? Must it sync across devices? Must a logged-in user see it everywhere? Each "yes" rules out the rungs below it; the first "no" bounds the rungs above.
  • Read the ladder as answers to requirements, not as a maturity scale. In memory answers "prove the behaviour"; browser storage answers "survive a reload on one device"; server memory answers "let the server see the cart, on one server, for a while"; the database answers "find it from anywhere, trust it, and keep it". A store on the browser-storage rung is not immature; it is correctly placed.
  • For the chosen rung, write the cost the concept record already names: browser storage serialises on every change and is invisible to the server; server memory is lost on restart and breaks with two servers; the database is a schema, a round trip per operation and real concurrency. If a cost is unacceptable, that is a requirement you had not written down — add it and climb.
  • Record, beside the choice, the single requirement that would move it up one rung. That line is what turns a rung into a decision instead of a habit, and it is what When Assumptions Change will read when the requirement arrives.

Asking the question until it has a rung

"We need persistence" is true of nearly every concept and picks no rung. The ladder below sharpens it: the better form names the event the cart must survive, and the best form names the event, the owner, and what the server needs — which is enough to read the rung straight off the persistence levels in the concept record.

Question quality
vagueWe need to persist the cart.
betterThe cart must survive a page reload and a closed browser.
bestAn anonymous shopper on one device must find their cart after a reload; the server does not need to see it until checkout; login does not exist yet. Which is the lowest rung that satisfies exactly that?

why The best form answers three questions the vague one hides — survive what, for whom, seen by whom — and those three are the rows of the ladder. It also names the requirement that would move the rung (checkout needing the cart, login), so the answer arrives with its own expiry.

The four rungs, each with its reason

The ladder is the concept record's persistence levels, read as a sequence of requirements. Every rung exists because the rung below it cannot answer a question; every rung costs something the one below did not. The map from requirement to rung is the whole decision — once the five survival questions are answered the rung is nearly forced.

The levels also shift where the cart's truth lives, which Representation Mapping traces: a JavaScript array in UI state, a JSON string in the browser, an object per session in server memory, rows with a unique constraint in the database.

Where the cart lives
  1. In memory
    The cart is a variable in the running process; createCart() on start.The behaviour must be right before anything wraps it, and a demo or a test has nothing to survive. Cost: nothing, and nothing survives.
  2. Browser storage
    The array is serialised to JSON on every change and loaded on start; one cart per browser.A shopper reloading the page is the first event a real store must survive, and no server needs to see the cart yet. Cost: serialise on every change; invisible to the server and other devices; a cleared browser loses it; stale products arrive from storage.
  3. Server memory
    The server holds carts keyed by session id; the browser calls the API for every change.Checkout or a stock check needs the cart on the server before there is a schema. Cost: lost on restart; breaks with a second server — a step, not a destination.
  4. Database
    cart(id, owner) and cart_item(cart_id, product_id, quantity) with a unique constraint on (cart_id, product_id).A logged-in shopper must find the cart from any device, checkout must trust it, and the business may want to see abandoned carts. Cost: a schema, a round trip per operation, and two tabs that now race — the rule that lived in code needs the constraint.

What each rung silently assumes

The costs in the record are the honest ones; the table below is what breaks when a rung is used past the requirement it was chosen for. Read the "cause" column as the assumption the rung was built on.

A rung used past its requirement
TriggerSymptomCauseResponse
Login ships; the cart is still in browser storage.A shopper logs in on their phone and the cart they built on their laptop is not there.Browser storage assumes one device is the owner; the requirement became "the user is the owner".Climb to the database rung and add the merge-at-login operation the concept lists as a modification.
A second server is added behind a load balancer.The cart appears and disappears between requests.Server memory assumes one process holds every session.Move the cart to a shared store; do not reach for sticky sessions as the fix unless the requirement is only to buy a week.
A product is deleted from the catalog.A cart loaded from storage renders, then total throws at checkout.Every persisted rung assumes the catalog it was saved against still exists.Decide the stale-product rule — drop, show as unavailable, or block checkout — and enforce it on load (What Can Go Wrong With a Cart).
Two tabs add the same product with a database cart and no constraint.Two rows for one product, or one add lost.The database rung assumes concurrent writers; the in-code rule "one entry per product" did not.The unique (cart_id, product_id) constraint, and an add written as an upsert (Invariants Under Concurrency).

The implementation ladder

Concept, examples, pseudocode, code, tests, production — for the concept this lesson is about. Code is the fourth tab, not the first.

concept Shopping Cart beginner
Build it step by step →

Shopping Cart = A temporary collection of products the user intends to purchase, held between browsing and checkout.

Identity, ownership, lifetime
  • Does a cart have identity? Yes, weakly. Two carts with the same items are still two carts, because each belongs to someone and will become a different order. It needs an id once it leaves memory; in memory the variable is the identity.
  • Who owns it? A shopper — a logged-in user or an anonymous session. The owner is part of the state because "my cart" has to be findable again.
  • How long does it exist? From the first add until checkout or abandonment. Whether it survives a reload, a closed browser or a login is not a property of the concept; it is a persistence decision made later, and each answer changes where the cart lives.
  • Should it survive reload? Usually yes for a store, usually no for a demo. V1 in memory says no; V2 browser storage says yes on one device; V3 server storage says yes everywhere the user is logged in.
  • Should it survive login? Only if an anonymous cart and a logged-in cart are merged — a rule that does not exist in V1 and appears as a modification later.
State it must remember
  • itemscollection of CartItemkeepThe cart is its items; without them nothing else means anything.
  • items[].productIdidkeepThe reference to what is being bought. The catalog owns the product; the cart only points at it.
  • items[].quantityinteger > 0keepTwo laptops is one entry with quantity 2, not two entries — the rule "one entry per product" needs a quantity to hold.
  • owneruser id or session iddependsSo the cart can be found again by the person it belongs to.
  • items[].productNamestringderiveIt would be convenient to render the cart without a catalog lookup.
  • items[].pricemoneydependsThe total needs a price per item.
  • totalmoneydropEvery screen shows the total.
  • currencycodedependsPrices need a currency to be added.
  • createdAttimestampdropAbandoned carts might be expired or emailed about.
Operations
  • update Add item the updated cart
  • delete Remove item the updated cart
  • update Change quantity the updated cart
  • read View items the list of entries — product id and quantity — for rendering
  • domain Calculate total the sum of price × quantity over the entries
  • delete Clear cart the empty cart
Rules that must always hold
  • Every quantity is greater than zero.
  • One logical entry per product.
  • The total is never negative.
  • An unknown product cannot be added.
  • Quantity cannot exceed available stock — if inventory is enforced here.

How to do it

Most important first.

  • Answer the five survival questions with yes/no in the concept's identity record before reading any storage documentation.
  • Pick the lowest rung whose answers match. Then, and only then, read that rung's documentation — browser storage APIs, session stores, or From Requirements to Tables-style schema design (Researching an Unknown Technology).
  • Write the rung's cost next to it from the concept record, and check each cost against the requirements you actually have.
  • State the promotion trigger: "moves to the database when login exists" or "when checkout must trust the total". A rung without a trigger is a rung chosen by feel.
  • Keep the wrapper thin — load, call the pure function, save — so the promotion, when it comes, changes two functions (The Cart Disappears).

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • The V2 store: anonymous only (yes), one device (yes), must survive a reload (yes), sync across devices (no — stop). The first "no" is at rung three; browser storage satisfies everything above it. Cost from the record: serialise on every change, invisible to the server, a cleared browser loses it. Trigger: "moves server-side when checkout must trust the cart or login arrives".
  • The same store after login ships: anonymous only (no — a logged-in shopper starts at checkout). The ladder is re-read: the server must find the cart by user, on any device, and merge the anonymous one at login. Browser storage cannot answer "any device"; server memory answers it for one server until a restart. The database rung is the first that satisfies the new requirement, and the cost — a cart table, cart_item rows, a unique (cart_id, product_id) constraint, a round trip per add — is now paid for a reason.
  • A demo built to show addItem in a talk: every question answered "no". The cart stays in memory, the ladder is not climbed, and the talk does not spend five minutes on a connection string.
  • The tempting middle rung, server memory, examined honestly: it answers "the server can see the cart" and nothing else. For the store it is a step passed through on the way to the database, useful only if checkout needs the cart on the server before a schema exists — and its failure (the second server, the restart) arrives without warning.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • The five survival answers are written down, and the chosen rung is the lowest one consistent with them.
  • You can state the rung's cost from the concept record and say which requirement makes each cost acceptable.
  • A promotion trigger exists in one sentence, and it names a requirement, not a date or a feeling.
  • Documentation for exactly one rung has been read; the others were ruled out before their docs were opened.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?Which of the five survival questions gets the first "no" for this concept, and which rung does that bound?
  • ?What does the chosen rung cost per operation, and which requirement makes that cost worth paying?
  • ?What single requirement would move this concept one rung up — and is it in sight?
  • ?Which other state in this feature is being persisted only because the concept beside it is?

What can go wrong

How the move itself fails
  • The ladder is climbed to the top because the top feels safe. A database cart for an anonymous single-device V2 pays for a schema, a round trip per operation and a concurrency problem to satisfy requirements that do not exist yet — and the round trip is felt by every add.
  • The ladder is never climbed. Login ships and the cart is still in the browser; the shopper logs in on a second device and finds nothing, and the promotion trigger was never written so nobody noticed the requirement arrive.
  • The middle rung becomes a destination. Server memory holds the carts, the store gets a second server, and half the shoppers lose their cart on every other request — the cost the record names as "breaks the moment there are two servers".
  • The ladder is applied to state that need not survive at all. The "is the checkout drawer open" flag is written to storage alongside the cart because the cart was; What Must Persist is the filter that runs before this ladder.
What the move costs
  • Choosing the lowest sufficient rung means a planned rewrite of the wrapper when the trigger fires; choosing the top rung once avoids the rewrite at the price of paying every cost from the first day.
  • The ladder needs the five questions answered by whoever owns the store, which is a conversation; choosing by familiarity needs nobody.
  • Writing the promotion trigger commits you to noticing when it fires, which is a small ongoing attention cost that habit-chosen rungs never charge.
Misreads
  • "Higher rungs are better." Higher rungs satisfy more survival requirements at more cost. A cart that never needs the server is worse, not better, on the database rung: slower per add, and racing between tabs for nothing.
  • "Browser storage is for toys." It is for the requirement "one device, no login, survive a reload" — which is most of an anonymous shopper's life on a real store, and a rung many production carts sit on until checkout.
  • "Server memory is a cheap database." It is a cache with the failure modes of a cache: restart and multiple servers. The record calls it a step, not a destination, for that reason.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERALThe survival questions and the ladder from process memory upward apply to any stateful concept; a draft message, a search filter and a file-upload progress bar each stop at a different rung.
  • SCALE-SPECIFICOn a single-server store the server-memory rung is a workable step; with several servers it is not a rung at all, and the ladder effectively jumps from browser storage to a shared store.
  • CONTESTEDSome engineers argue the cart should be server-side from the first version, because checkout will need it there and the browser-storage rung is a throwaway: build once against the rung you will end on. The strongest form of that view is that the wrapper is cheap either way and the schema is a day's work, so the rewrite saved is not worth the requirement-tracking discipline. The reply is that the day's work buys a round trip per add and a concurrency problem before login exists; both sides agree the choice must be written down with its trigger.
  • ILLUSTRATIVEThe V2 store, the talk demo and the two-server incident are invented to show the shape of the ladder; no real deployment is described.

Where the depth lives

This domain asks the question and hands the answer off by name.