DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two localStorage keys written by the same layout, one first touch and one last touch

You can run this experiment on our site in about ten seconds. Open:

https://cogniprep.app/?ref=alex-7q2k
Enter fullscreen mode Exit fullscreen mode

Look at the address bar. The parameter is gone; you are on
https://cogniprep.app/. Now open the console:

localStorage.getItem('cogniprep:pending-referral');
// '{"code":"ALEX7Q2K","at":1791620088074}'
Enter fullscreen mode Exit fullscreen mode

Lowercase in, uppercase out, hyphen removed, timestamp attached, query string
cleaned up behind it. That is four decisions in one page load and every one of
them was a bug first.

Why it is stored at all

A referral code arrives with someone who has never heard of the product. They
click a friend's link, sign up, play a few free games, and come back days later.
Almost nobody buys in the session that brought them here.

So the code cannot be "applied" on arrival, because there is nothing to apply it
to yet. It gets parked, and the purchase surfaces inside the account read it back
whenever the visitor eventually reaches one. The alternative, which is where we started, was
to make the friend remember a string, navigate to a pricing page, find the "Have
a code?" field and type it. Every one of those steps loses people.

Why the parameter is stripped

The capture component is mounted once at the app root and does this after banking
the code:

url.searchParams.delete(REFERRAL_LINK_PARAM);
const query = url.searchParams.toString();
window.history.replaceState(null, '', `${url.pathname}${query ? `?${query}` : ''}${url.hash}`);
Enter fullscreen mode Exit fullscreen mode

Two reasons, both learned the unpleasant way.

The person who clicked the link is the single most likely person to copy that URL
out of the address bar and send it to somebody else. If the parameter is still
there, the reward silently goes to whoever is named in it rather than to the
person who actually did the referring. A URL carrying an attribution token is not
a shareable URL.

And a ?ref= that lingers through signup ends up attached to every page of the
funnel in analytics, so every report has to learn to ignore it.

replaceState rather than a router push, so this does not register as a
navigation and does not re-run the other root level trackers.

The whole effect is also keyed on usePathname rather than useSearchParams,
which sounds backwards for something that reads the query string. Reading
useSearchParams from a component in the root layout opts the entire tree into
client side rendering. Reading window.location.href inside the effect gets the
same string for free.

The sibling key, and the opposite rule

There is a second store mounted in the same place, with a nearly identical
implementation and the opposite overwrite rule:

localStorage.getItem('cogniprep:entry-intent');
// '{"kind":"provider","id":"<a provider slug>","at":1790669068435}'
Enter fullscreen mode Exit fullscreen mode

Entry intent is first touch. It answers "what did they come here for", and
later browsing must not overwrite it. Someone who arrives on a page about one
assessment and then browses six others came here for the first one.

The pending referral code is last touch, because attribution is a different
question. If someone clicks Alex's link today and Sam's link next week, the
reward belongs to Sam. The most recent deliberate click is the one that brought
them back.

You can see both rules at once in the experiment above. The timestamp on my
entry-intent entry was eleven days old and the ?ref= visit did not touch it,
while the referral entry was replaced the moment I arrived. Two modules, almost
the same code, one line of difference:

if (readEntryIntent()) return; // first-touch wins
Enter fullscreen mode Exit fullscreen mode

Both windows are 30 days, and that is deliberate rather than coincidental. They
answer versions of the same question, which is "is this arrival still the reason
this person is here". Someone who buys four months after clicking a link was not
really referred.

What happens to junk

Try the same experiment with a code that is too short:

https://cogniprep.app/?ref=ab
Enter fullscreen mode Exit fullscreen mode

Nothing is stored. Codes are normalised by uppercasing and dropping everything
outside A-Z0-9, then length checked against the same bounds the server uses, 4
to 24 characters after normalisation. A value that cannot possibly match is
discarded in the browser rather than being carried to an endpoint that will say
no.

The part worth noticing is that a previously banked valid code survives the junk
link, because the write only happens if the new value passes. The parameter is
still stripped either way, so a mangled link cannot clobber a real referral and
cannot be re-shared either.

It is allowed to be forgeable

Everything above runs in the browser, in a store the user can edit freely, and
that is fine, because the store gates nothing.

The code is re-resolved on the server when it is used, and the price is
recalculated from the code's own row at checkout. The stored string's only power
is to save someone typing. A hand edited value can be rejected; it cannot be
honoured. Which is why the module is written as best effort throughout: every
access wrapped in try, stale entries ignored, null returned on anything
unexpected, and a clearPendingReferralCode that runs when the server says the
code is unusable for this visitor, so that someone who followed their own link
does not re-attempt the same doomed lookup on every pricing surface forever.

The design rule I took from this: a client side store is safe to be generous
with exactly when the server does not believe it. The moment one of these keys
starts gating anything, both of them become an attack surface, and the 30 lines
of try wrapping stop being paranoia and start being insufficient.

Run the experiment on https://cogniprep.app/?ref=alex-7q2k, then clear the key.
The products this eventually discounts are listed at
https://cogniprep.app/pricing; the single collapsed "Have a code?" field that
reads the stored value back sits on the purchase surfaces inside the account,
which is the last place a visitor reaches and the first place the code is worth
anything.

Top comments (0)