DEV Community

Daniel Pertu
Daniel Pertu

Posted on

157 lines added, 1,849 deleted: what an anonymous path actually cost us

We shipped a feature that let a signed-out visitor play one free game per provider, and removed it three days later. The removal commit is the interesting artefact: 59 files changed, 157 insertions, 1,849 deletions.

The point of this post is not that the product decision flipped. It is that the 1,849 number was not concentrated where you would guess. Ten files were deleted outright and they account for about 1,100 of it. The other 700 lines were spread across files that had nothing to do with guests and everything to do with the fact that the app now had two kinds of visitor.

Where the cost actually sat

The deleted directory is the boring part: a context provider, a gate component, a claim component, a serializer, a validator, a signed permit, and their tests. That is a feature's own code, and a feature's own code is always the cheapest part of it to remove.

The expensive lines were in files like lib/auth/post-auth-destination.ts, which decides where you land after signing in. Guest play had widened a union type there:

export type LandingIntent = EntryIntentInput | { kind: 'claim'; gameId: string };
Enter fullscreen mode Exit fullscreen mode

That third member existed because a visitor who finished a game without an account had the result parked in their browser, and after signing in they had to land back on that exact game's route so the signed-in runner could find and save it. Any other destination stranded the result, which is the one outcome the whole flow existed to prevent.

One extra union member does not sound like much. In practice it meant the redirect ranking had a fourth priority level, the cookie encoding had a third prefix, a client component took a second prop, and the tests for the redirect grew a case class of their own. Removing it took 28 lines out of a test file that otherwise never mentioned guests.

There is a general rule here that I keep relearning. A feature's cost is not the size of its directory. It is the number of existing decisions it adds a case to. A directory can be deleted in one commit; a case added to a shared decision has to be found.

The second copy of the completion screen

components/games/shared/GameCompletion.tsx lost 118 lines and gained 45. That component shows you how you did after a game, and with guest play it had to render two different worlds: a signed-in player sees their score and its context, a signed-out player sees a prompt to create an account so the result they just earned is not lost.

Those are not two branches of one screen. They are two screens with different value propositions, different calls to action, and different failure modes, living in one file behind a flag. Every change to that component during those three days had to be reasoned about twice.

The same doubling showed up in smaller amounts nearly everywhere: the game runner, the error boundary wrapper, the shell, the game card, the analytics event definitions (32 lines of guest-specific events), and the API wrapper, which had grown a branch to accept a permit in place of a session.

The part I was sad to delete

The claim route was good code. When a signed-out game was claimed by a new account, the route did not trust the score the browser had computed:

The body is the guest session the browser parked in localStorage: a game id
and the completed reducer state. It is validated (a free-tier game, a
completed state, a plausible sitting), then scored HERE rather than in the
browser. A guest session is never scored before it is claimed, and scoring it
on the server means the number that lands in the database was produced by the
same scorers from a state the server has seen.
Enter fullscreen mode Exit fullscreen mode

Everything after the scoring step reused the ordinary save path, so a claimed game was subject to the same limits and rules as a signed-in one. That is the right shape for a claim endpoint: validate, re-derive on the server, then rejoin the existing path as early as possible. I would write it the same way again. It just has no reason to exist once nothing can be played without an account.

What replaced it

Nothing, which is the point. Twenty-seven provider pages each lost a handful of lines and now render one thing where they previously rendered two, and the whole "which visitor am I" question disappeared from the game shell.

See it for yourself. Open cogniprep.app/games/pymetrics in a signed-out window and look at what the twelve game cards link to. Every one of them is /signup?provider=pymetrics, including the two that are free to play. One visitor kind, one destination, no parked state in localStorage waiting to be reconciled with an account that does not exist yet.

If you are about to add an anonymous path to an app that currently requires an account, the useful estimate is not how big the anonymous feature is. It is how many shared decisions in your codebase are about to learn that there are two kinds of user. For us that list was the post-auth redirect, the cookie format, the completion screen, the analytics schema and the API wrapper, and that list is what the 700 lines were.

Top comments (0)