Locale of a user

Two chains, because half of each side’s links do not exist on the other. Storage is absent from a Worker, and the browser’s language API inside the Workers runtime is a constant.

The server chain

Two locales is why there are two answers, translating in the browser is the client’s own chain, and i18n is the seam both resolve through.

URL parameter → account → cookie → request header → default.

LinkWhat it is
ParameterAn explicit ?lang= on this request. Highest, because it is the most recent thing anybody said
AccountThe signed-in reader’s stored locale
CookieA choice made in this browser
HeaderAccept-Language, as the whole weighted list
DefaultThe project default, always last

The browser chain

Query → account → storage → what the server said → default.

Both orders are configuration, and the default is the last resort whether or not it is listed.

The account outranks the device, and that is the interesting decision

A reader picks Spanish on their phone. They open your app on a laptop where storage holds an older choice.

They read Spanish. The account is where a person’s locale lives, so a device’s memory does not override a person’s stated preference.

And a signed-in reader’s choice is written through to their account rather than only to storage — through a seam, because the call that writes that column belongs to the auth capability rather than to this one.

The URL parameter stays above both, because it is the most explicit thing available: somebody following a link that names a language wants that language.

The header is honored as a whole list

This is the part most implementations get wrong.

Accept-Language carries weighted preferences, not one value. A header asking for Portuguese, then Spanish, then English — from a project that serves no Portuguese — is a request for Spanish.

Reading only the head answers English, which is wrong and looks like a bug in your language support rather than in your parsing.

A malformed header does not throw

A header full of wildcards, underscored tags and empty tokens falls through to the default rather than raising.

Every one of those makes the standard locale constructor throw, and a 500 on the request path is not a negotiation strategy.

What null means

The negotiated locale is null in a Worker that does not compose this capability.

That is “nothing was negotiated”, which is not the same fact as “the default was chosen.” Only i18n ever sets it, and the translator works either way.

Reading it

c.var.locale?.direction;   // "ltr" | "rtl", for the document
c.var.t.t("app/nav.settings");

You need the tags themselves for two things: setting the language and direction attributes on a document, and stamping a locale onto a row — an email job, most often, because the body is rendered later in a Workflow with no request on it.

Storing somebody’s choice

The auth capability owns the column, and a signed-in reader’s choice is persisted there.

For a signed-out reader, the cookie is the whole memory. That is correct: there is nobody to store it against.

Letting somebody choose

Offer only locales you actually serve. A picker listing a language that falls back to English is a picker that lies.

A ?lang= link is the cheapest possible switcher and it works for signed-out readers, crawlers and shared links — which is most of why it is first in the chain.

Check it worked

  • ?lang= overrides everything, signed in or out
  • A signed-in reader’s account choice beats the browser’s
  • A weighted header picks a language from the middle of the list
  • A malformed header gets the default rather than a 500
  • The document’s language and direction attributes match what was negotiated
ESC