ReferenceIdentity Storage Contract

Identity Storage Contract

The supported way for a second tracking surface on the same page to share one anonymous identity with the Mythic Analytics SDK.

Why this exists

Some pages track from two places at once. The canonical case is a Shopify app: a theme app embed runs the SDK in the normal browser context, and a Web Pixel runs in a sandbox with no DOM — the only way to capture checkout, because checkout is not scriptable.

Both surfaces are the same visitor. If they pick different anonymous IDs, the visit becomes two people: browsing under one ID, purchase under another, and the purchase carries no landing page or campaign data.

This page documents the identity record in storage as a supported integration surface so a second surface can agree with the SDK on one ID, rather than reverse-engineering it.

Do not decide your ID from a single read at startup. On a cold load, script order is not guaranteed: we have measured the same storefront with the SDK writing first on one visit and the second surface executing first on another — 77ms apart. A surface running in the same page context is safe — its read-then-write is atomic because JavaScript is single-threaded. But a surface that reaches storage through an async bridge (a sandboxed Shopify Web Pixel reads via the Web Pixel Manager) can read an empty slot, lose the CPU, and act on stale emptiness. If that is your situation, either wait (poll briefly before minting) or send immediately and heal the split with $create_alias. Mint-and-forget is the only wrong option.

The record

Stored under <prefix>identity, where <prefix> is mythic_<publishable key>_ unless you set persistence_name. So by default:

mythic_pk_a1b2c3..._identity

The value is JSON:

{
  "distinctId": "019fca56-1210-7041-b114-a9f784ccef38",
  "anonymousId": "019fca56-1210-7041-b114-a9f784ccef38",
  "deviceId": "019fca56-1210-7042-9b69-8bb884a7800f",
  "userId": "user_123",
  "aliases": []
}
distinctIdstring
Required

The current identity — the anonymous ID until identify() is called, the user ID afterwards. This is the field a second surface must match.

anonymousIdstring
Required

The pre-identification ID, retained after identify() so anonymous and identified activity can be stitched. Backfilled from distinctId if absent.

deviceIdstring
Required

Device-scoped ID that survives reset(). Do not mint this yourself — see device_id is real state.

userIdstring

Set only after identify(). Absent while anonymous.

aliasesstring[]
Required

Previous IDs, capped at 10. Must be an array if you write the record — identify() operates on it directly. Backfilled to [] if missing, but only from SDK 2.230.5 onward.

Timing guarantee

From 2.232.0, the loader itself seeds the identity record and a session record into storage at parse time — the moment init is called, before the SDK core module is even fetched. Measured on a live storefront, the records exist ~150ms into the page, before a pixel sandbox can realistically boot. The core adopts the seeded identity verbatim on arrival and enriches the seeded session with entry attribution; the IDs never change.

Independently of the seed, the SDK writes the identity record synchronously, before it returns from initialization — at init, on identify(), and on reset(). It does not ride the SDK's normal write debounce.

This matters because every other key the SDK stores (session, super_properties, geo_data, …) is staged in memory and flushed about a second later. Identity is deliberately exempt so a second surface reading storage sees the current identity rather than an empty slot.

Measured on a live Shopify storefront: the identity record is in localStorage at ~94ms, while the debounced keys arrive at ~1193ms.

The guarantee is that the write happens as early as the SDK can make it — not that it happens before your surface runs. A surface that executes very early in the page's life can genuinely arrive first; see the callout above.

This guarantee is about when the SDK writes, not about when your surface can read. A surface that reads through an async proxy rather than touching localStorage directly — a sandboxed Shopify Web Pixel reads through the Web Pixel Manager — is not guaranteed to observe the write synchronously. Confirm your surface sees it before depending on the timing.

Precedence

A stored record always wins. On initialization the SDK reads the record first, adopts it verbatim if present, and only then writes. It never overwrites a record it found — a returning visitor keeps their ID.

If you write the record before the SDK initializes, the SDK adopts your ID. If the SDK initializes first, it writes its own and yours would overwrite it, which is what you must avoid — always read before writing.

const KEY = `mythic_${publishableKey}_identity`;

const existing = JSON.parse(localStorage.getItem(KEY) || 'null');
if (existing?.distinctId) {
  useThisId(existing.distinctId);        // the SDK already decided — adopt it
} else {
  const id = crypto.randomUUID();
  localStorage.setItem(KEY, JSON.stringify({
    distinctId: id,
    anonymousId: id,
    aliases: [],
    // no deviceId — let the SDK own it
  }));
  useThisId(id);
}

Omitting deviceId is supported: the SDK fills it from stored state, or mints one if there is none.

The mirrors

Alongside the record, the SDK writes <prefix>distinct_id and <prefix>device_id. They are not equivalent, and the difference matters.

distinct_idderived

Never read this. Pure derived state — written for redundancy and never read back by the SDK. Treat the identity record as the only source of truth for the current ID.

device_idauthoritative

Real state. The SDK reads it whenever the identity record is missing or has no deviceId, and under the default localStorage+cookie persistence it is also mirrored to a cookie while the identity record is not. It therefore outlives identity.

device_id is real state

Because device_id can survive when identity does not — a storage clear under ITP, for example — "no identity record" does not mean "new browser". If you mint a fresh deviceId in that state you fork a device the SDK would otherwise have recognised. Carry through an existing device_id, or omit the field and let the SDK resolve it.

Healing a lost race

If your surface cannot hold its first event (or the record was still absent when it fired), send the event with your minted ID, keep watching storage, and when the identity record appears with a different distinctId, emit one alias event:

{
  "event": "$create_alias",
  "distinct_id": "<the record's distinctId — the SDK's ID>",
  "properties": {
    "alias": "<the ID you minted and already sent under>"
  }
}

Mythic's identity resolution merges the two visitors server-side — anonymous-to-anonymous alias merges are allowed — and events already sent under your minted ID reattach to the real visitor, including their attribution. Switch to the record's ID for all subsequent events. Send the alias once.

What this does not repair: session-level analytics keep the early event's own session row, and a visitor who bounces before the record ever appears stays split. Every client-side strategy shares that tail.

Compatibility

Guaranteed within the 2.x line:

  • The key names — <prefix>identity, <prefix>distinct_id, <prefix>device_id.
  • The prefix scheme — mythic_<publishable key>_, overridable via persistence_name.
  • The field names and meanings documented above.
  • A stored record is adopted, never overwritten, at initialization.
  • The identity record is written before initialization returns.
  • Fields you omit are backfilled rather than treated as an error.

Not guaranteed: additional fields may be added, so preserve fields you do not recognise instead of rewriting the record wholesale.

Version detection

The write timing guarantee and aliases backfilling arrived in 2.230.5.

window.mythic.version is set once initialization resolves, from 2.231.0 onward. Because the property itself is what shipped in 2.231.0, undefined after init means the build predates it — which does not tell you whether it predates 2.230.5. If you need to distinguish those, check behaviour rather than version: read the identity record immediately after the SDK's script has run and see whether it is populated.

const version = window.mythic?.version;   // undefined until init resolves