Restoreable user intent
Selected resource, active tab, filters, sort, pagination, comparison set, and an optional portable editor snapshot.
Architecture decision guide · Version 2.0
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
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.
Establish a typed URL-state boundary with explicit ownership, canonical serialization, resilient parsing, history semantics, privacy controls, and a server-backed overflow path.
Selected resource, active tab, filters, sort, pagination, comparison set, and an optional portable editor snapshot.
Hover, animation phase, pending requests, transient validation, drag position before commit, and framework internals.
Autosaved drafts, recent documents, theme, density, panel widths, and last-known valid checkpoints.
Permissions, prices, entitlement, workflow status, audit history, multi-user data, revocation, and expiration.
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
The current Mermaid Live Editor source confirms a layered design rather than a single URL-only mechanism.
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]
serializeState() stringifies the state, DEFLATE-compresses UTF-8 bytes at level 9, Base64URL-encodes them, and prefixes the codec name.
The editor reads from location.hash and debounces history.replaceState() updates by 250 milliseconds.
The input state is persisted under codeStore. Incoming Mermaid configuration is examined for unsafe paths and markup patterns.
Mermaid explicitly removes the hash from tracked page URLs because the hash contains diagram data.
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
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 class | Examples | Required property | Preferred home | URL role |
|---|---|---|---|---|
| Resource identity | Document ID, workspace, diagram slug | Stable, addressable, authorization-bound | Server + path | Primary |
| Server-relevant view | Page, filter, sort, locale, date range | Changes fetched or rendered result | Query | Primary |
| Portable client snapshot | Unsaved source, canvas graph, playground config | Self-contained and safe to disclose | Fragment or share record | Conditional |
| Ephemeral interaction | Hover, focus, open tooltip, live pointer position | High frequency and short-lived | Memory | Avoid |
| Local recovery | Draft, recent work, panel dimensions | Device-scoped and resumable | IndexedDB or localStorage | Indirect |
| Authoritative business state | Price, permission, approval, entitlement | Integrity, authorization, audit | Server | Never trust |
| Collaborative state | Shared document, presence, CRDT operations | Concurrent mutation and durable ordering | Collaboration service | Snapshot only |
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.
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
Query parameters and fragments are not interchangeable. They differ in server visibility, caching, referrer behavior, navigation, indexing, and namespace ownership.
| Channel | Server receives it | Portable | Best use | Critical constraint |
|---|---|---|---|---|
Path/docs/123 | Yes | Yes | Resource identity and hierarchy | Must remain routable on refresh and deep link |
Query?tab=preview&filter=aws | Yes | Yes | Small semantic view state and server inputs | Can affect logs, cache keys, SSR, analytics, and referrers |
Fragment#s=v2.deflate... | No | Yes | Client-only portable snapshot | Single namespace; conflicts with anchors, hash routing, and text fragments |
| history.state | No | No | Small per-entry restoration hints | Browser-specific serialized-size limits and no link portability |
| Local persistence | No | No | Draft recovery and preferences | Origin/device scoped; stale and shared-device risks |
Share record?share=01J... | Yes | Yes | Large, access-controlled, expiring, collaborative state | Requires lifecycle, authorization, storage, and availability |
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.
A document has one fragment. Using it for s=... means native section anchors,
hash routers, media fragments, and #:~:text=... cannot independently occupy that slot.
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.
?filter=aws&filter=security is readable and supported by getAll(). Use JSON only when nesting is truly required.
Presence flags are compact but cannot represent false explicitly. 0/1 is compact. true/false is clearer.
Omitting defaults shortens links, but changing defaults later can alter old link meaning. Version the contract or serialize semantic defaults.
Preserve unrelated query parameters such as campaign tags, platform controls, and integration-owned values.
Execution lifecycle
URL state becomes unreliable when multiple components parse and mutate it independently. Centralize the grammar, migrations, validation, canonicalization, history policy, and error recovery.
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
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
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.
Change the model. Compare wire formats. Write only when explicitly requested.
Paste a full Mermaid Live URL, a pako: token, or this document’s v2.deflate token. Decoded data is rendered as text only.
No token decoded.
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
URL state crosses browser, CDN, proxy, application, analytics, messaging, and support workflows. Design for the smallest credible infrastructure path, not only the local browser.
CompressionStream and DecompressionStream have been widely available since May 2023 and work in Web Workers.
URLSearchParams uses form encoding, spaces become +, and mutating parameters can reserialize an equivalent URL differently.
CloudFront cache policies can include selected query strings in the cache key and automatically forward those values to the origin.
Track only approved path and query fields. Mermaid’s current implementation deliberately excludes the fragment that contains diagram data.
| Budget | Recommended default | Action | Basis |
|---|---|---|---|
| Readable query payload | Target ≤ 2 KB; hard product cap ≤ 4 KB | Move complex data to snapshot or share record | Supportability, referrers, logs, cache-key control |
| Compressed fragment | Soft warning at 8 KB | Offer a short share record | Messaging, QR, clipboard, browser-history, and support reliability |
| Total portable URL | Hard product cap at 16 KB | Require share record | Common infrastructure limits include 16 KB URLs/request lines |
| Decoded snapshot | 256 KB default cap | Reject before application hydration | Memory, parsing, rendering, and decompression-bomb control |
| history.state | Small derivable hints only | Store large data elsewhere | Browsers may persist entries and impose implementation-specific limits |
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.
| Option | Best fit | Strength | Control to preserve |
|---|---|---|---|
| Native URL + History | Single-file SPA, framework-neutral library, small surface | Lowest dependency and clearest browser semantics | Schema, canonicalization, history policy, tests |
| TanStack Router | Typed React or Solid routing with structured search state | JSON-first parsing, validation, typed route integration | Avoid opaque nested state when readable first-level parameters suffice |
| nuqs | React applications wanting useState-like URL state | Typed parsers, batching, multi-framework adapters | History mode, rate limiting, server notification semantics |
| React Router | Existing React Router applications | Native-like URLSearchParams integration | Setting search params causes navigation |
| Next.js App Router | SSR/RSC applications | Native History calls integrate with router state | Distinguish client-only filters from server-rendering inputs |
Trust and privacy
Treat every pasted, bookmarked, redirected, restored, and manually edited URL as hostile input.
| Threat | Failure mode | Required control | Residual limitation |
|---|---|---|---|
| Sensitive-data exposure | Query appears in logs, browser history, cache, monitoring, or referrer | Keep secrets and personal data out; redact analytics; restrictive referrer policy | Fragments still appear in the client, history, clipboard, and shared link |
| Parameter tampering | User changes role, price, entitlement, object ID, or policy field | Server-side authorization and recomputation; URL is only a hint | Signed data can prove integrity, not current authorization |
| Injection | Decoded state reaches HTML, CSS, URL, script, import, or fetch sinks | Typed schema, contextual output encoding, safe DOM APIs, allowlists | Rich user-authored formats may need dedicated sanitizers |
| Prototype pollution | Nested keys modify object prototypes | Reject dangerous keys; parse into null-prototype or schema-owned objects | Third-party merge utilities remain part of the trust chain |
| Resource exhaustion | Huge token, decompression bomb, deep JSON, massive arrays | Encoded and decoded limits, bounded stream read, depth and count limits | Client devices have different memory ceilings |
| Replay and stale links | Old snapshot applies outdated policy or data | Version, issued-at, expiry, server lookup, revocation where consequential | Offline snapshots cannot guarantee freshness |
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.
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
The following contract is suitable for generated SPAs, playgrounds, internal tools, and enterprise editors.
/workspaces/:workspaceId/documents/:documentId
?tab=preview
&filter=security
&filter=platform
#s=v2.deflate.<base64url-payload>
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.
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 };
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": 2,
"schema": "url-state/document-snapshot",
"createdAt": "2026-07-21T00:00:00Z",
"snapshot": {
"code": "flowchart LR ..."
}
}
| Test group | Required cases | Pass criterion |
|---|---|---|
| Golden URLs | Defaults, arrays, Unicode, empty values, ordering, migration fixtures | Stable canonical output and round-trip equality |
| Malformed input | Unknown version, codec, invalid Base64URL, invalid UTF-8, invalid JSON | Controlled fallback; no partial hydration |
| Resource limits | Oversized encoded token, decompressed bytes, depth, arrays, strings | Early rejection with bounded memory |
| History | Replace, push, Back, Forward, initial entry, duplicate writes | Expected restoration and no feedback loop |
| Security | Prototype keys, HTML, script URLs, unauthorized IDs, stale signed state | Rejected, sanitized, or server-authorized as designed |
| Infrastructure | CDN cache policy, SSR, referrer, analytics, deep-link reload | Documented and verified deployed behavior |
| Accessibility | Back behavior, focus restoration, screen-reader announcement, keyboard-only use | Navigation remains perceivable and operable |
Evidence and provenance
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.
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.
Codec prefix, JSON serialization, Pako DEFLATE level 9, Base64URL, backward-compatible Base64 fallback.
Mermaid Live Editor · serde.tsLocal recovery, validation, sanitization, URL generation, and 250 ms replaceState subscription.
Mermaid Live Editor · state.svelte.tsInitial state is loaded from window.location.hash.slice(1).
Tracked URL includes origin, path, and query but intentionally excludes the fragment containing diagram data.
Mermaid Live Editor · stats.tsFragments are client-processed and are not sent with the request.
MDN · URI fragmentThe fragment namespace also supports user-agent text-fragment directives.
MDN · Text fragmentsSelected query strings can be included in CDN cache keys and are then forwarded to the origin.
AWS · Understand cache policiesHTTP request targets exclude fragment components; fragments remain visible to user-agent scripts and extensions.
RFC 9110 · HTTP SemanticspushState adds a session-history entry; replaceState updates the current entry; popstate restores traversed state.
MDN · Working with the History APIFires when the active history entry changes during traversal, not when state methods are called directly.
MDN · popstateNewly baseline in 2026; centralizes SPA navigation events, interception, entries, state, success, and error handling.
MDN · Navigation APISame-origin constraint, serializable state requirement, call-frequency errors, and no hashchange event.
MDN · History.pushStateNative streaming compression and decompression are widely available and supported in Web Workers.
MDN · CompressionStreamForm-encoding rules, plus-for-space behavior, and potentially surprising URL reserialization.
MDN · URL.searchParamsCurrent Workers platform limit documents a 16 KB URL size.
Cloudflare · Workers limitsApplication Load Balancer request-line hard limit is 16 KB.
AWS · How Elastic Load Balancing worksBrowsers may persist serialized state to disk and impose implementation-specific size limits.
MDN · History.pushStateJSON-first search parsing, validation, type safety, and search-state navigation.
TanStack Router · Search ParamsType-safe React search-parameter state with a useState-like API and framework adapters.
nuqs · Official documentationSetting search parameters causes a navigation.
React Router · useSearchParamsNative pushState and replaceState calls integrate with Next.js router state and search-parameter hooks.
Next.js · Linking and navigatingSensitive values in query strings can appear in referrers, logs, shared systems, browser history, and cache even under HTTPS.
OWASP · Information exposure through query stringsUntrusted data requires context-appropriate handling and safe rendering sinks.
OWASP · XSS Prevention Cheat SheetControls whether origin, path, and query data are sent in the Referer header; fragments are excluded.
MDN · Referrer-Policy