Skip to content

Visitor Identity

The storefront knows every visitor from their first request, logged in or not, so their browsing (page views, recently viewed, likes, the basket) can be tied together and carried over when they register or log in. This page covers how that identity is issued and stored, and what it costs a request.

The cookies

UserResolutionFilter resolves the visitor on every storefront request from two signed cookies, both written by BadgerUserCookieSigner:

  • BDGR_USR (<userId>$<hmac>) is the visitor's identity.
  • BDGR_VIS (<lastActiveEpochSeconds>$<hmac>) is a hint that the visitor is browse-only, so the request can skip reading their user document. The signature covers the BDGR_USR user id, so the hint stops being valid as soon as anything switches BDGR_USR to another user (a login, for example).
Request What happens
No cookie (first visit) A transient User is built in memory with a fresh ObjectId and BDGR_USR is set. Nothing is written in the request.
Valid BDGR_USR and a valid BDGR_VIS The visitor is rebuilt from the cookies. No database read.
Valid BDGR_USR, document found The stored User is loaded (one _id read).
Valid BDGR_USR, no document, id issued in the last day The worker hasn't created it yet, or the visitor has only browsed listing pages (see Visitors who aren't saved). The visitor keeps the id; the cookie is not reissued.
Valid BDGR_USR, no document, older id The document was purged or erased. The visitor gets a new identity, as for a first visit.
Invalid signature Treated as a first visit.

BDGR_USR lasts 30 days (CookieUtils.SITE_COOKIE_EXPIRY). It is written again whenever the visitor's activity is recorded (at most every 5 minutes), so it runs out after 30 days idle, not 30 days after the first visit.

The one-day window is UserResolutionFilter.PENDING_CREATION_WINDOW. The issue time is read from the ObjectId, so the BDGR_USR format is unchanged. A visitor kept under their id this way is marked unloaded, so saving them never overwrites a document of the same id (another site's user, say: the lookup returns nothing for those too).

Browse-only visitors

A browse-only visitor has nothing on their User but tracking data: id, site group, registration time and IP, last-active date, location, and the bot flag (BrowseOnlyVisitors). There's no basket, no login and no captured details, so the only field a request acts on is last-active, and the hint carries it.

BrowseOnlyVisitors is an allow-list of those tracking fields. A field added to User counts as state by default, which is the safe direction: wrongly treating a visitor with state as browse-only would hide their basket from them.

The hint is reconciled just before each response commits (OnCommittedResponseWrapper):

  • A visitor who is still browse-only gets the hint, or has its last-active stamp refreshed if it changed.
  • The hint is withdrawn (Max-Age=0) the moment a request gives the visitor state. The filter checks the request's User. As a safety net, StatefulUserSaveTracker also records every user saved with state on the request thread, in case some code saved a separate copy.
  • No hint is given on a first visit or while the document is still being created. It appears on a later request, once the document exists.

A user rebuilt from cookies is marked unloaded. If a request gives them state and saves them, UserServiceImpl first fills every field the request didn't set from the stored document. So the whole-document save never wipes their tracked history.

Old pods ignore BDGR_VIS and read the user as before, so a rolling deploy is safe in both directions.

Visitors who aren't saved

A visitor with no document only gets one from a request worth keeping (UserResolutionFilter.shouldSaveNewVisitor). These requests don't save one:

  • a GET or HEAD from a bot (BotDetectionService.isBot), on any page;
  • a GET or HEAD of a listing page: /c/**, /collection/**, /search and /search/**.

Crawlers that keep no cookies walk listing pages and their facet combinations by the thousand, and each hit used to write a user. On 2026-10-23 one did that to Halden at about 27 requests a second.

The visitor still gets BDGR_USR, but no BDGR_VIS hint (the hint would skip the read that lets a later request save them). Their next request that is worth keeping, within the one-day window, saves them under the same id: a product page, the home page, any POST (adding to the basket, signing in), or a handler calling UserResolutionFilter.persistDeferredUser. A bot's POST is still saved, marked for deletion. Page views of unsaved visitors are still recorded against their id.

Writes happen on the worker

The request thread never writes the user document. VisitorActivityService publishes a VisitorActivity message to visitor-activity-queue, off the request thread, and the worker's VisitorActivityListener applies it:

  • NEW_VISITOR is sent after a first-visit request returns 2xx (as before, 404s, redirects and /error don't create users, nor do the requests above). The worker inserts the document only if it's absent (UserService.createAnonymousUserIfAbsent), and counts the visitor (badger.shop.events, event=visitor) only when its insert creates it, since the message is re-sent until the document exists.
  • ACTIVITY is sent when a returning visitor's last-active date is more than 5 minutes old, or their location changes. It carries only the visit (time and location). The worker reads the stored user, adds the location to their history and applies a targeted $set (UserService.recordVisit). A browse-only request never sees the history, so it can't compute it.

If the broker can't take the message, the publisher applies it directly, so no visitor is lost when RabbitMQ is down.

Anything stateful a visitor does before the worker catches up (adding to the basket, registering, capturing an email) saves the request's User itself, which creates the document straight away. The worker's later insert finds it and does nothing.

Code that loads the current user from the database by id must allow for the document not existing yet, and fall back to the request's User (see SetUserWithCapturedDetailsIfNullProcessor).

The basket

OrderService.retrieveCurrentOrderOrCreate only looks for a basket when the user has a currentOrderId. If that basket can no longer be used (typically because it has been placed) and there's no newer one, currentOrderId is cleared with a conditional $unset (UserService.clearCurrentOrder). Otherwise every later request would repeat both lookups until the customer started a new basket.

Logging in

On password and returning-OIDC login, VisitorActivityService.linkToAccount moves what the visitor did anonymously onto their account, off the request thread:

  • page views (PageViewHistoryService.moveHistory, merged in date order),
  • likes (LikeService.transferLikes, dropping any the account already has),
  • recently viewed products (SiteUsageStatisticsService.mergeRecentlyViewedProducts).

The basket was already merged at login, and a first OIDC login upgrades the anonymous user in place, so neither needs linking.

Page views are only ever written with atomic updates ($push), both by the worker's PageViewedEventReceiver and by the move, so the two can't overwrite each other.

Bots

BotDetectionService flags bots when their user is created. A bot reading pages gets no user document at all (see above). One that posts (adding to a basket, a form) is still created, already marked for deletion, and MarkedForDeletionOrderAndUserCleaner removes it on its hourly run. See also Anonymous Visitors and Bots.

Measuring it

These appear in performance statistics:

  • VisitorActivityServiceImpl on the web nodes.
  • UserServiceImpl.createAnonymousUserIfAbsent and recordVisit on the worker.
  • UserServiceImpl.findUserByIdAndSiteContext on the web nodes. It should drop as browse-only visitors start carrying the hint.

Before this work, UserServiceImpl.persistTransientUser ran in the request for every first-time visitor: 26k times a week in production at about 18 ms each, most of them one-hit crawlers. Returning visitors each paid a findUserByIdAndSiteContext read (38k a week at about 14 ms).