Open nakodo.app and look in the Application tab of your browser's dev tools. localStorage is empty. sessionStorage is empty. The only cookie is the one that keeps you signed in, and on a first visit there is not even that.
Analytics is still running. This is the config that does it, and the parts of it that turned out to have consequences.
The init call
posthog.init(POSTHOG_TOKEN, {
api_host: POSTHOG_PATH,
ui_host: POSTHOG_HOSTS.ui,
defaults: "2026-08-30",
persistence: "memory",
person_profiles: "identified_only",
respect_dnt: true,
session_recording: { maskAllInputs: true },
capture_exceptions: true,
disable_surveys: true,
});
persistence: "memory" is the whole post. The visitor's anonymous ID and their session ID live in the page's memory and nowhere else. A client-side navigation keeps them, because the JavaScript context survives. A reload does not. A second tab gets its own.
Why not the setting that is literally called cookieless
posthog-js has cookieless_mode: "always", which is the obvious thing to reach for. It does the same job better in one respect: PostHog counts your visitors with a server-side hash of IP, user agent and hostname, so your unique-user numbers are not quite as wrong.
The PostHog docs list what it costs, and one line decided it for us:
Session replay and surveys: Both are disabled if the user has not given cookie consent. This is because both features rely on storing data in cookies/local storage.
Session replay is the only part of analytics I have ever actually changed a product decision from. Event counts tell me that eleven people opened the campaign setup flow and four finished it. A recording tells me that one of them spent forty seconds on a field whose label was wrong. Giving that up to improve a unique-visitor count would be trading the useful thing for the vanity thing.
So: no storage, but keep the recorder. persistence: "memory" is the setting that does that, and the comment in our source says why, because this is exactly the kind of line somebody changes in a year on the grounds that it looks like an accident.
What it costs, honestly
Every reload is a new anonymous person. Our anonymous unique-visitor count is inflated by an amount I cannot measure, and any insight of the form "how many people came back next week" is worthless for signed-out traffic.
That is survivable here because the questions worth asking are about accounts, not sessions. Did sign-ups turn into launched campaigns. Do people on the free plan hit the point where the paid plans make sense. Every one of those is keyed to a user ID, which does not need storage because the server knows it.
If you run a content site where returning-visitor rate is the number your whole business is judged on, do not copy this.
The bit that broke, and the hidden field that fixes it
Sign-up on Nakodo is: fill in the form, get an email, click the link. That link opens a new tab.
A new tab is a new JavaScript context, which with memory persistence means a brand new anonymous ID. So the twenty minutes somebody spent reading how it works and the pricing page before signing up were about to be stranded on an ID that nothing would ever claim.
The form carries it across by hand:
action={(form) => {
if (mode === "signup" && posthog.__loaded) form.set("anon", posthog.get_distinct_id());
action(form);
}}
and the Server Action stitches it:
const anon = String(form.get("anon") ?? "");
if (/^[\w-]{1,200}$/.test(anon)) track(data.user.id, "$identify", { $anon_distinct_id: anon });
$identify with $anon_distinct_id is PostHog's merge event. Sending it from the server rather than the browser is what makes it work at all: by the time the account exists, the browser that owns that anonymous ID may well have been closed.
The regex is there because the value arrives from a form field and ends up in an ingestion payload. It is an ID or it is nothing.
Identifying on every load
Because nothing is stored, the client has to be told who you are on each page load, not just at sign-in:
export function Identify({ id, email, plan }: { id: string; email: string; plan: string }) {
useEffect(() => {
if (!posthog.__loaded) return;
if (posthog.get_distinct_id() === id) posthog.setPersonProperties({ email, plan });
else posthog.identify(id, { email, plan });
}, [id, email, plan]);
return null;
}
It sits in the signed-in layout and renders nothing. The guard matters: identify() with the ID you already have is a wasted merge event on every single navigation that remounts the component, so the already-identified case goes to setPersonProperties instead.
Signing out is a full page load, which forgets the ID the only way it can be forgotten: by throwing away the page.
The other four lines
person_profiles: "identified_only" means signed-out visitors produce events but no person record. There is no useful profile to build for somebody we cannot name, and every one of them would be a row that exists for one page load.
respect_dnt: true means a browser sending Do Not Track or Global Privacy Control sends nothing at all. Our privacy notice says so in the analytics section, which is the actual reason the flag is in the code rather than in the project settings: a dashboard toggle can be changed by anybody with access, and then the page is lying.
session_recording: { maskAllInputs: true } is set in code for the same reason. Every input is recorded as asterisks, and the places where creator conversations are rendered carry a ph-mask class on top of that. The app handles other people's email addresses; a recording that captured them would be a data breach with a replay button.
Finally, three paths are never initialised at all:
const UNTRACKED = ["/admin", "/o/", "/unsubscribe"];
The operator panel, and the opt-out and unsubscribe pages that creators open from our emails. The operator is not a customer, and the creators are people who have just told us they want less from us, which makes recording them an odd way to respond.
The summary I would want
If you want no browser storage and you want session replay, those are not the same setting, and the one with "cookieless" in the name gives you the first by taking away the second. persistence: "memory" is the trade in the other direction, and the price is that an anonymous visitor only exists for as long as their tab does. Decide which of your numbers that ruins before you ship it, not after.
Top comments (0)