Architecture decision guide · Version 2.0

URL state is a protocol boundary—not a persistence layer.

Use the URL to preserve identity, navigation intent, and portable checkpoints. Keep live runtime state in the application, recovery state in local persistence, and authoritative or governed state behind a server record.

Decision first

Represent only the state that must survive navigation, refresh, or sharing.

URL state is most valuable when it restores a meaningful user-visible location. It becomes brittle when it mirrors an internal store, carries authority, or grows without a budget.

Central recommendation

Establish a typed URL-state boundary with explicit ownership, canonical serialization, resilient parsing, history semantics, privacy controls, and a server-backed overflow path.

Use the URL

Restoreable user intent

Selected resource, active tab, filters, sort, pagination, comparison set, and an optional portable editor snapshot.

Keep in memory

High-frequency runtime state

Hover, animation phase, pending requests, transient validation, drag position before commit, and framework internals.

Keep locally

Recovery and preference state

Autosaved drafts, recent documents, theme, density, panel widths, and last-known valid checkpoints.

Keep server-side

Authority and collaboration

Permissions, prices, entitlement, workflow status, audit history, multi-user data, revocation, and expiration.

Reversibility matters

Choosing query parameters is easy to change internally but creates a public link contract. Choosing a fragment for snapshots is also externally visible and consumes the fragment namespace. Version both decisions from the beginning.

Observed implementation

Mermaid uses a compressed fragment, local recovery, and deliberate analytics redaction.

The current Mermaid Live Editor source confirms a layered design rather than a single URL-only mechanism.

Observed Mermaid pipeline. External share state and local recovery are separate. Source inspected 2026-07-21
Mermaid Live Editor state serialization and persistence flow Input statecode + config + view JSON.stringifyexplicit State object DEFLATEPako · level 9 Base64URLjs-base64 URL-safe #pako:<payload>replaceState · 250 ms debounce localStoragecodeStore recovery Analytics filterorigin + path + query only
Show Mermaid source for this diagram
Mermaid source
flowchart LR
  A[Input state] --> B[JSON.stringify]
  B --> C[DEFLATE\nPako level 9]
  C --> D[Base64URL]
  D --> E[#pako payload\nreplaceState after 250 ms]
  A --> F[localStorage\ncodeStore recovery]
  E --> G[Analytics URL\norigin + path + query only]
Confirmed

Serialization contract

serializeState() stringifies the state, DEFLATE-compresses UTF-8 bytes at level 9, Base64URL-encodes them, and prefixes the codec name.

Confirmed

Recovery and sanitization

The input state is persisted under codeStore. Incoming Mermaid configuration is examined for unsafe paths and markup patterns.

Confirmed

Analytics minimization

Mermaid explicitly removes the hash from tracked page URLs because the hash contains diagram data.

What Mermaid proves—and what it does not

It proves that a compressed fragment is practical for a browser editor and that recovery, security checks, and analytics redaction belong beside serialization. It does not establish a universal URL-size limit, guarantee confidentiality, solve fragment namespace collisions, or make a snapshot collaborative.

Knowledge model

Classify state before choosing a URL representation.

The same field can belong in different channels depending on whether it identifies a resource, changes server output, restores a view, or merely helps the local interface recover.

State classExamplesRequired propertyPreferred homeURL role
Resource identityDocument ID, workspace, diagram slugStable, addressable, authorization-boundServer + pathPrimary
Server-relevant viewPage, filter, sort, locale, date rangeChanges fetched or rendered resultQueryPrimary
Portable client snapshotUnsaved source, canvas graph, playground configSelf-contained and safe to discloseFragment or share recordConditional
Ephemeral interactionHover, focus, open tooltip, live pointer positionHigh frequency and short-livedMemoryAvoid
Local recoveryDraft, recent work, panel dimensionsDevice-scoped and resumableIndexedDB or localStorageIndirect
Authoritative business statePrice, permission, approval, entitlementIntegrity, authorization, auditServerNever trust
Collaborative stateShared document, presence, CRDT operationsConcurrent mutation and durable orderingCollaboration serviceSnapshot only
Projection rule

Create a separate ShareableState or UrlState type. Never serialize a framework store wholesale. The URL contract should be smaller, slower-changing, and safer than the internal application model.

TypeScript · explicit projection
interface ApplicationState {
  document: DocumentModel;
  session: AuthenticatedSession;
  runtime: RuntimeState;
  ui: InternalUiState;
}

interface UrlStateV2 {
  version: 2;
  documentId?: string;
  view: {
    tab: "editor" | "preview";
    filters: string[];
    zoom: number;
  };
  snapshot?: PortableDocumentSnapshot;
}

function projectUrlState(state: ApplicationState): UrlStateV2 {
  return {
    version: 2,
    documentId: state.document.persistedId,
    view: normalizeView(state.ui),
    snapshot: state.document.persistedId ? undefined : projectSnapshot(state.document)
  };
}

Representation contract

Use each URL component for the semantics it can reliably carry.

Query parameters and fragments are not interchangeable. They differ in server visibility, caching, referrer behavior, navigation, indexing, and namespace ownership.

ChannelServer receives itPortableBest useCritical constraint
Path
/docs/123
YesYesResource identity and hierarchyMust remain routable on refresh and deep link
Query
?tab=preview&filter=aws
YesYesSmall semantic view state and server inputsCan affect logs, cache keys, SSR, analytics, and referrers
Fragment
#s=v2.deflate...
NoYesClient-only portable snapshotSingle namespace; conflicts with anchors, hash routing, and text fragments
history.stateNoNoSmall per-entry restoration hintsBrowser-specific serialized-size limits and no link portability
Local persistenceNoNoDraft recovery and preferencesOrigin/device scoped; stale and shared-device risks
Share record
?share=01J...
YesYesLarge, access-controlled, expiring, collaborative stateRequires lifecycle, authorization, storage, and availability
Recommended ownership rule

Do not encode the same field in both query and fragment. Let query parameters own view intent and let the fragment own the portable document snapshot. Non-overlapping ownership removes merge precedence, prevents contradictory links, and makes canonicalization deterministic.

Fragment namespace collision

A document has one fragment. Using it for s=... means native section anchors, hash routers, media fragments, and #:~:text=... cannot independently occupy that slot.

Cache and rendering consequence

Query strings can become CDN cache-key inputs and server-rendering inputs. Fragment content cannot affect the initial server response because it is not transmitted.

Arrays

Prefer repeated keys

?filter=aws&filter=security is readable and supported by getAll(). Use JSON only when nesting is truly required.

Booleans

Choose one grammar

Presence flags are compact but cannot represent false explicitly. 0/1 is compact. true/false is clearer.

Defaults

Omit with caution

Omitting defaults shortens links, but changing defaults later can alter old link meaning. Version the contract or serialize semantic defaults.

Ownership

Delete only owned keys

Preserve unrelated query parameters such as campaign tags, platform controls, and integration-owned values.

Execution lifecycle

One boundary module should control the complete read–write cycle.

URL state becomes unreliable when multiple components parse and mutate it independently. Centralize the grammar, migrations, validation, canonicalization, history policy, and error recovery.

Recommended lifecycle. External input enters through a bounded and versioned boundary.One mutation gateway
URL state read, validation, update, and write lifecycle External URLuntrusted input Parse envelopeversion + codec Decodebounded bytes Migrateold → current Validate + normalizetrusted canonical state Hydrate applicationsource = navigation User changes statesource = interaction Project shareable statedrop runtime fields Canonicalizesort · omit · round Budget + encodequery · fragment · share Write navigationreplace or push Failure path: defaults + visible recovery + diagnosticsnever partially trust malformed state
Show Mermaid source for this diagram
Mermaid source
flowchart LR
  URL[External URL\nuntrusted] --> P[Parse envelope]
  P --> D[Decode\nbounded]
  D --> M[Migrate]
  M --> V[Validate + normalize]
  V --> H[Hydrate application]
  H --> U[User changes state]
  U --> S[Project shareable state]
  S --> C[Canonicalize]
  C --> B[Budget + encode]
  B --> W[replaceState or pushState]
  W --> H
  P -. failure .-> R[Defaults + recovery + diagnostics]
  D -. failure .-> R
  V -. failure .-> R
Prevent feedback loops

Tag the source of each update. A navigation-originated hydration should not immediately generate an equivalent URL write. Compare canonical representations before writing, and funnel every mutation through one gateway.

Interactive evidence

Measure readable query state against encoded and compressed snapshots.

This lab uses native CompressionStream("deflate") when available. It also decodes Mermaid pako: links because Pako DEFLATE and the browser deflate stream use the same zlib-wrapped format.

Portable state laboratory

Change the model. Compare wire formats. Write only when explicitly requested.

Checking native compression…
Filters
1.00×
Snapshot JSONUTF-8 bytes
Readable queryURL characters
Base64URLURL characters
DEFLATE + Base64URLURL characters
Readable query projection
Compressed snapshot
Normalized state

Mermaid link inspector

Paste a full Mermaid Live URL, a pako: token, or this document’s v2.deflate token. Decoded data is rendered as text only.

Decoded output
No token decoded.
The lab exposes an architectural tradeoff

Writing a snapshot to the fragment replaces this document’s section anchor. That is the fragment namespace collision in practice. The preview is safe; write actions are explicit.

Operational consequences

Serialization choices affect caching, rendering, observability, and link reliability.

URL state crosses browser, CDN, proxy, application, analytics, messaging, and support workflows. Design for the smallest credible infrastructure path, not only the local browser.

Native compression

Reduce dependency surface

CompressionStream and DecompressionStream have been widely available since May 2023 and work in Web Workers.

Canonical URLs

Do not assume no-op serialization

URLSearchParams uses form encoding, spaces become +, and mutating parameters can reserialize an equivalent URL differently.

Cache policy

Allowlist query keys

CloudFront cache policies can include selected query strings in the cache key and automatically forward those values to the origin.

Analytics

Build a safe URL explicitly

Track only approved path and query fields. Mermaid’s current implementation deliberately excludes the fragment that contains diagram data.

Recommended product budgets

BudgetRecommended defaultActionBasis
Readable query payloadTarget ≤ 2 KB; hard product cap ≤ 4 KBMove complex data to snapshot or share recordSupportability, referrers, logs, cache-key control
Compressed fragmentSoft warning at 8 KBOffer a short share recordMessaging, QR, clipboard, browser-history, and support reliability
Total portable URLHard product cap at 16 KBRequire share recordCommon infrastructure limits include 16 KB URLs/request lines
Decoded snapshot256 KB default capReject before application hydrationMemory, parsing, rendering, and decompression-bomb control
history.stateSmall derivable hints onlyStore large data elsewhereBrowsers may persist entries and impose implementation-specific limits
These are engineering defaults, not standards

Cloudflare Workers documents a 16 KB URL limit. AWS Application Load Balancer documents a 16 KB request-line limit. Fragments are not sent in requests, but the complete link still traverses browsers, clipboard tools, messaging systems, QR codes, and support channels.

Framework selection

OptionBest fitStrengthControl to preserve
Native URL + HistorySingle-file SPA, framework-neutral library, small surfaceLowest dependency and clearest browser semanticsSchema, canonicalization, history policy, tests
TanStack RouterTyped React or Solid routing with structured search stateJSON-first parsing, validation, typed route integrationAvoid opaque nested state when readable first-level parameters suffice
nuqsReact applications wanting useState-like URL stateTyped parsers, batching, multi-framework adaptersHistory mode, rate limiting, server notification semantics
React RouterExisting React Router applicationsNative-like URLSearchParams integrationSetting search params causes navigation
Next.js App RouterSSR/RSC applicationsNative History calls integrate with router stateDistinguish client-only filters from server-rendering inputs

Trust and privacy

Encoding is not encryption. A URL is user-controlled and disclosure-prone.

Treat every pasted, bookmarked, redirected, restored, and manually edited URL as hostile input.

Threat boundary. The URL can be observed and modified before the application trusts it.Validate before hydrate
URL state threat paths and controls Shared URLobservable + mutable Disclosure pathshistory · logs · referrer · clipboard Tampering pathsedited values · malformed codec Boundary controlsbudget · decode · migrateschema · sanitize · authorize Trusted application statesafe renderingserver revalidates authority
ThreatFailure modeRequired controlResidual limitation
Sensitive-data exposureQuery appears in logs, browser history, cache, monitoring, or referrerKeep secrets and personal data out; redact analytics; restrictive referrer policyFragments still appear in the client, history, clipboard, and shared link
Parameter tamperingUser changes role, price, entitlement, object ID, or policy fieldServer-side authorization and recomputation; URL is only a hintSigned data can prove integrity, not current authorization
InjectionDecoded state reaches HTML, CSS, URL, script, import, or fetch sinksTyped schema, contextual output encoding, safe DOM APIs, allowlistsRich user-authored formats may need dedicated sanitizers
Prototype pollutionNested keys modify object prototypesReject dangerous keys; parse into null-prototype or schema-owned objectsThird-party merge utilities remain part of the trust chain
Resource exhaustionHuge token, decompression bomb, deep JSON, massive arraysEncoded and decoded limits, bounded stream read, depth and count limitsClient devices have different memory ceilings
Replay and stale linksOld snapshot applies outdated policy or dataVersion, issued-at, expiry, server lookup, revocation where consequentialOffline snapshots cannot guarantee freshness
Never authorize from URL state

A role, price, content right, policy result, or entitlement in the URL is untrusted presentation input. The server must derive authority from identity, current policy, and authoritative data.

Integrity and confidentiality are different requirements

An HMAC or digital signature can detect tampering. It does not hide the payload. Authenticated encryption can hide and protect data, but browser-only decryption requires key management and usually indicates the state belongs behind a server share record instead.

Adoption standard

Use a versioned URL envelope and a small, testable boundary API.

The following contract is suitable for generated SPAs, playgrounds, internal tools, and enterprise editors.

Canonical URL shape
/workspaces/:workspaceId/documents/:documentId
  ?tab=preview
  &filter=security
  &filter=platform
  #s=v2.deflate.<base64url-payload>
Use the fragment only when its namespace is intentionally reserved

Applications that need native section anchors or hash routing should place the snapshot behind a share ID, use a dedicated editor route whose fragment is reserved, or define one structured fragment grammar and own all fragment behavior.

Boundary interface

TypeScript · recommended boundary
export interface UrlStateBoundary<T> {
  read(url: URL): Promise<ReadResult<T>>;
  project(application: ApplicationState): T;
  normalize(candidate: unknown): T;
  migrate(envelope: UnknownEnvelope): CurrentEnvelope<T>;
  encode(state: T, target: "query" | "fragment"): Promise<URL>;
  write(url: URL, mode: "replace" | "push"): void;
  analyticsUrl(url: URL): URL;
}

export type ReadResult<T> =
  | { ok: true; state: T; source: "query" | "fragment" | "default" }
  | { ok: false; fallback: T; error: UrlStateError };

Native DEFLATE codec

TypeScript · Compression Streams API
async function deflate(bytes: Uint8Array): Promise<Uint8Array> {
  const stream = new Blob([bytes])
    .stream()
    .pipeThrough(new CompressionStream("deflate"));

  return new Uint8Array(await new Response(stream).arrayBuffer());
}

async function inflateBounded(
  compressed: Uint8Array,
  maximumBytes = 256_000,
): Promise<Uint8Array> {
  const reader = new Blob([compressed])
    .stream()
    .pipeThrough(new DecompressionStream("deflate"))
    .getReader();

  const chunks: Uint8Array[] = [];
  let total = 0;

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > maximumBytes) {
      await reader.cancel("Decoded state exceeds limit");
      throw new RangeError("Decoded state exceeds limit");
    }
    chunks.push(value);
  }

  return concat(chunks, total);
}

Version envelope

JSON · wire contract
{
  "version": 2,
  "schema": "url-state/document-snapshot",
  "createdAt": "2026-07-21T00:00:00Z",
  "snapshot": {
    "code": "flowchart LR ..."
  }
}

Release validation matrix

Test groupRequired casesPass criterion
Golden URLsDefaults, arrays, Unicode, empty values, ordering, migration fixturesStable canonical output and round-trip equality
Malformed inputUnknown version, codec, invalid Base64URL, invalid UTF-8, invalid JSONControlled fallback; no partial hydration
Resource limitsOversized encoded token, decompressed bytes, depth, arrays, stringsEarly rejection with bounded memory
HistoryReplace, push, Back, Forward, initial entry, duplicate writesExpected restoration and no feedback loop
SecurityPrototype keys, HTML, script URLs, unauthorized IDs, stale signed stateRejected, sanitized, or server-authorized as designed
InfrastructureCDN cache policy, SSR, referrer, analytics, deep-link reloadDocumented and verified deployed behavior
AccessibilityBack behavior, focus restoration, screen-reader announcement, keyboard-only useNavigation remains perceivable and operable
  1. Path identifies durable resources.
  2. Query carries concise semantic state and uses an allowlisted grammar.
  3. Fragment snapshots are optional, versioned, bounded, and used only where the fragment namespace is reserved.
  4. Continuous mutations replace the active entry; committed navigation pushes a new entry.
  5. Local recovery is separate from portable sharing.
  6. Server share records handle size, access control, collaboration, expiration, and revocation.
  7. Analytics and logs use a sanitized URL projection.
  8. URL state never grants authority.

Evidence and provenance

Primary-source registry and claim traceability

Facts are linked to primary source code, specifications, official platform documentation, security guidance, infrastructure documentation, and framework documentation. Product budgets and recommendations are identified as analysis.

Methodology

Source code was inspected on the Mermaid develop branch on 2026-07-21. Platform behavior was cross-checked against current MDN, WHATWG, RFC, OWASP, AWS, Cloudflare, and official framework documentation. Recommendations synthesize those facts for enterprise SPA design.

Mermaid serialization

S01

Codec prefix, JSON serialization, Pako DEFLATE level 9, Base64URL, backward-compatible Base64 fallback.

Mermaid Live Editor · serde.ts

Mermaid analytics-safe URL

S04

Tracked URL includes origin, path, and query but intentionally excludes the fragment containing diagram data.

Mermaid Live Editor · stats.ts

URI fragments

S05

Fragments are client-processed and are not sent with the request.

MDN · URI fragment

Text fragments

S06

The fragment namespace also supports user-agent text-fragment directives.

MDN · Text fragments

HTTP target URI and fragments

S08

HTTP request targets exclude fragment components; fragments remain visible to user-agent scripts and extensions.

RFC 9110 · HTTP Semantics

popstate event

S10

Fires when the active history entry changes during traversal, not when state methods are called directly.

MDN · popstate

Navigation API

S11

Newly baseline in 2026; centralizes SPA navigation events, interception, entries, state, success, and error handling.

MDN · Navigation API

pushState method behavior

S12

Same-origin constraint, serializable state requirement, call-frequency errors, and no hashchange event.

MDN · History.pushState

Compression Streams API

S13

Native streaming compression and decompression are widely available and supported in Web Workers.

MDN · CompressionStream

URLSearchParams serialization

S14

Form-encoding rules, plus-for-space behavior, and potentially surprising URL reserialization.

MDN · URL.searchParams

history.state implementation limits

S17

Browsers may persist serialized state to disk and impose implementation-specific size limits.

MDN · History.pushState

Next.js native History integration

S21

Native pushState and replaceState calls integrate with Next.js router state and search-parameter hooks.

Next.js · Linking and navigating

Referrer policy

S24

Controls whether origin, path, and query data are sent in the Referer header; fragments are excluded.

MDN · Referrer-Policy