Most links simply point to a page. Sometimes users also need to share the page’s current state, such as the selected filters or the contents of an editor. Query parameters are often enough for this, but as the amount of data grows, you also need to consider how to encode and compress it.
I ran into this while building Schemagic, a visual JSON Schema editor. The site has no user accounts, so schemas are shared through URLs, just like code in TypeScript Playground and similar tools.
When to store state in a URL
Before looking at encoding and compression, consider whether URL-based storage fits the use case.
A compressed payload is no longer human-readable, but that is usually fine when the original data would be impractical to edit manually. For state with only a few parameters, however, query parameters are a better choice: a URL such as ?type=article is easy to understand and edit directly in the address bar.
URL-based storage has limitations. It does not support collaborative editing or access controls, and once you share the data, you cannot revoke it. Those features require storing the state on the backend.
Where to store state in a URL
You can store state in three parts of a URL: the path, query parameters, or the fragment.
The path usually identifies the page itself rather than its current state.
Query parameters work well for small bits of state, such as filters, sorting, or the active editor. The catch is that the browser sends them to the server whenever someone opens the link. Schemagic schemas can be large and may contain private data, so sending them to the backend is not an option.
The URL fragment is the best fit. The fragment is the part after #, and browsers do not include it in HTTP requests. This keeps the state in the browser instead of sending it to the server when someone opens the URL. TypeScript Playground takes the same approach and stores the code in a fragment: #code/MYewdgzgLgBArgJwDYwLwwEQAspQA4QBcA9MQJYBuAhgNZlgB0AJgKYXEYDcQA.
Serialization and encoding
A naive approach is to serialize the object as JSON and append the resulting string directly to the URL:
const dataStr = JSON.stringify(dataObj);
const url = `${BASE_URL}#${dataStr}`;
This approach has two problems: some JSON characters require percent-encoding, and the resulting string may be too long.
Compression addresses the second problem, but it produces binary data that cannot be added to a URL directly. The compressed data must first be converted to URL-safe text. The js-base64 library can encode it as Base64url, a variant of Base64 with a URL-safe alphabet.
The encoding pipeline is state → serialization → compression → Base64url. Decoding follows the same steps in reverse.
Browser history
In TypeScript Playground, the URL changes only after the editor loses focus, and navigating through the browser history does not restore previous code states. When state changes often, compressing it after every edit can slow down the interface. Debouncing URL updates avoids this by running compression only after a short pause.
Creating a history entry for every edit would clutter the browser history and make the Back button undo changes one by one instead of leaving the page. You can use history.replaceState() to update the URL without creating a new entry or reloading the page.
URL length limits
URL length limits depend on the browser and how the link is shared. For example, an email client or messaging app may truncate a long link. There is no universal safe maximum, so choose a project-specific limit based on the amount of state the application needs to support, then test URLs near that limit in the browsers and apps users are likely to use.
Compression methods
When choosing a compression method, consider its compression ratio, compression and decompression speed, effect on the client bundle size, and browser support. I compared lz-string, pako, fflate, and the native CompressionStream in two formats: deflate-raw and brotli.
For the benchmarks, I used two JSON schemas: GitHub Funding and JSON Resume. I used Vitest’s benchmarking tools in Browser Mode to measure the length of each compressed and encoded payload, along with compression and decompression times.
Encoded state size
| Method | GitHub Funding | JSON Resume |
|---|---|---|
| without compression | 2,248 (100%) | 8,833 (100%) |
| pako | 1,112 (49%) | 3,043 (34%) |
| fflate | 1,112 (49%) | 3,095 (35%) |
| lz-string | 1,729 (77%) | 5,125 (58%) |
| CompressionStream deflate-raw | 1,099 (49%) | 3,004 (34%) |
| CompressionStream brotli | 888 (40%) | 2,295 (26%) |
For both schemas, lz-string produced longer output than the alternatives. pako, fflate, and CompressionStream with deflate-raw produced output of similar length, which is expected since they all use the DEFLATE codec.
Compression and decompression speed
- GitHub Funding
- JSON Resume
012345678↑ Time (ms)pakofflatelz-stringCompressionStreamdeflate-rawCompressionStreambrotli0.1490.3360.1010.2680.3591.440.1730.3693.468.95
- GitHub Funding
- JSON Resume
00.050.10.150.20.250.30.350.4↑ Time (ms)pakofflatelz-stringCompressionStreamdeflate-rawCompressionStreambrotli0.04670.1340.1040.1770.1440.4360.1310.2020.1360.207
In these benchmarks, lz-string produced larger output and compressed and decompressed more slowly than the DEFLATE-based alternatives, so I did not consider it further.
CompressionStream with brotli took noticeably longer to compress the data, and browser support for the format is still limited. Use it only when minimizing URL length is critical and all target browsers support it.
Bundle size
CompressionStream is built into the browser, so it doesn’t require an external library or add anything to the client bundle. The gzipped size of fflate is 4.61 KB, compared with 15 KB for pako. Since the two libraries have nearly identical performance and compression ratios, fflate’s smaller bundle makes it the better choice.
Choosing a compression method
That leaves two practical options with similar speed and compression ratios: CompressionStream with deflate-raw and fflate.
CompressionStream keeps the client bundle smaller but requires browser support for the API. fflate works without the native API and provides more control over compression, including support for custom dictionaries.
For Schemagic, I chose fflate and use the #dr:... prefix to identify the deflate-raw format. This lets me switch formats later without breaking existing links.
Putting it together
Before compressing state into a URL, make sure this approach fits the use case. For small amounts of state, prefer human-readable query parameters.
For larger payloads:
- Store the payload in the URL fragment so that it is not sent to the server.
- Serialize the payload, compress it with a suitable method, then encode the result as Base64url.
- Debounce frequent URL updates and use
history.replaceState()to avoid creating a history entry for every change. - If the compression format may change, identify it in the fragment so that existing links remain decodable.










