Building ToolRunner·9 min read

The URL Is the Database: Shareable Tool State Without a Server

How ToolRunner packs entire tool sessions into query parameters with debounced replaceState, lz-string compression, and a hard 8,000-character URL budget.

by Dowon Oh

Every tool on ToolRunner runs entirely in the browser — no accounts, no backend, no database. That constraint is the whole point of the site, but it creates an obvious problem: if there is no server, where does "save" live? When you build a cron expression in the Cron Generator and want to show it to a teammate, there is nothing to persist and no record ID to hand out. My answer was the oldest state store on the web: the URL itself. Every keystroke in a tool is serialized into query parameters, so copying the address bar is the save button, and pasting the link is the restore. Getting that to work without janky history entries, without corrupting old links, and without blowing past what browsers and chat apps will tolerate in a URL took one small hook — useUrlSync — and a handful of deliberate constants. This article walks through exactly how it works, straight from the source.

Three constants and a contract

The entire mechanism lives in src/hooks/useUrlSync.ts and is governed by three constants:

const MAX_URL_LENGTH = 8000;
const COMPRESS_PREFIX = 'z.';
const COMPRESS_THRESHOLD = 50;

The contract the hook offers to tool pages is intentionally boring. A page hands over a flat record of strings, and the hook keeps the query string in sync:

// src/pages/CronGenerator.tsx
useUrlSync({
  expr: expression,
  tz: timezone,
  fmt: sixField ? '6' : '5',
});

Reading goes through a companion function, getUrlParam, which pages call inside useState initializers so the URL is consulted exactly once, on mount:

const [expression, setExpression] = useState(() =>
  getUrlParam('expr', '* * * * *'),
);
const [timezone, setTimezone] = useState(() =>
  getUrlParam('tz', getUserTimezone()),
);

That is the whole API surface. The Regex Tester syncs pattern, text, and flags; the JSON Viewer syncs its input document; the Diff Checker syncs both sides of the comparison. No page knows anything about compression, debouncing, or length budgets — those decisions are made once, in the hook, and I want to walk through each of them.

Writing state with replaceState, not pushState

The naive implementation of URL-synced state calls history.pushState on every change, and it is a disaster in practice: type ten characters into a text area and the Back button now has to be clicked ten times to leave the page. The MDN History API documentation draws the distinction clearly — pushState adds a session history entry, replaceState modifies the current one in place. For continuously-edited tool state, replace is the only sane choice: the URL always reflects the latest state, and navigation behaves the way users expect.

The hook also debounces the write by 300 milliseconds. Serializing state and rewriting the URL on every keystroke is wasted work when the user is mid-word, so each state change resets a timer, and only when input pauses for 300ms does the write actually happen:

useEffect(() => {
  if (timerRef.current) clearTimeout(timerRef.current);

  timerRef.current = setTimeout(() => {
    const url = new URL(window.location.href);

    for (const [key, value] of Object.entries(paramsRef.current)) {
      if (value) {
        url.searchParams.set(key, encodeParamValue(value));
      } else {
        url.searchParams.delete(key);
      }
    }

    const newHref = url.toString();
    if (newHref.length <= MAX_URL_LENGTH && newHref !== window.location.href) {
      window.history.replaceState(null, '', newHref);
    }
  }, 300);

  return () => {
    if (timerRef.current) clearTimeout(timerRef.current);
  };
}, [JSON.stringify(params)]);

Two details in that loop are worth calling out. First, empty values are deleted, not written as ?input= — a cleared text area produces a clean URL instead of a trail of hollow parameters. Second, the effect's dependency is JSON.stringify(params), with the live values read through a ref. That keeps the debounce timer stable across renders while guaranteeing the eventual write always sees the freshest state, not a stale closure from 300ms ago.

The 8,000-character budget

The guard on the last line — newHref.length <= MAX_URL_LENGTH — is the hook's safety valve, and the behavior when the budget is exceeded is deliberate: the write is silently skipped. The tool keeps working with its full in-memory state; the URL simply stops tracking it. I chose degradation over truncation because a truncated serialized state is worse than none — a cron expression cut off mid-field or a half-encoded JSON document would restore into garbage on the other end. A link that reproduces slightly stale state is annoying; a link that reproduces corrupted state is a bug report.

Why 8,000? There is no single number in any spec — the WHATWG URL standard imposes no length limit — but real-world software does. The classic floor is Internet Explorer's documented limit of 2,083 characters, which is why "keep URLs under 2,000 characters" survives as folklore. Modern browsers accept far longer URLs, but the practical ceiling is set by everything a shared link passes through: proxies, chat apps that unfurl previews, server access logs, and CDNs with their own header-size limits. 8,000 sits comfortably inside what current browsers and mainstream infrastructure handle, while being roughly four times IE's old ceiling — enough headroom that, with compression doing its part, real tool sessions almost never hit the wall.

Compression above 50 characters, marked with a prefix

Short values go into the URL as-is. A cron expression like */15 * * * * or a timezone like Asia/Seoul is perfectly readable in the address bar, and I want it to stay that way — a human glancing at ?expr=*%2F15+*+*+*+*&tz=Asia%2FSeoul can still tell what the link contains. But tools like the JSON Viewer routinely carry kilobytes of input, and that is where the budget gets eaten. The encoder makes the call per value:

export function encodeParamValue(value: string): string {
  if (value.length <= COMPRESS_THRESHOLD) return value;
  return COMPRESS_PREFIX + compressToEncodedURIComponent(value);
}

Anything longer than 50 characters is run through lz-string's compressToEncodedURIComponent, which was built for exactly this job: LZ-based compression whose output alphabet is already URL-safe, so URLSearchParams has nothing left to percent-escape. The z. prefix is the marker that tells the reader "this value is compressed" — a plain value cannot be confused with a compressed one, and old bookmarked links from before compression existed still parse as raw text.

Here is what it looks like with a real payload — a 168-character JSON array of three cron job definitions pasted into the JSON Viewer. Without compression, URLSearchParams percent-escapes every quote, brace, and comma, and the value alone balloons to 280 characters:

https://toolrunner.dev/json-viewer/?input=%5B%7B%22id%22%3A1%2C%22name%22%3A%22nightly-backup%22%2C%22schedule%22%3A%220+3+*+*+*%22%7D%2C%7B%22id%22%3A2...
(280 characters of value)

With compression, the same state fits in 178 characters — the z. marker plus 176 characters of lz-string output (this is the actual output of the site's own encodeParamValue, and it round-trips):

https://toolrunner.dev/json-viewer/?input=z.NobwRAlgJmBcCMAaMA7AhgWwKZ1RA5gBYAuANgJ4C0ARmgMYDWArgA5jIDOdhWUTpOWGAAMAAgDMogFTTpYAL6Jw0OACZk6bLlIB7fJQBOO4mmIQdKdmC48-A3GLEyZwhUsgxY4jZkFgeaKTEhJTcWIxWNrz8flIA9PAArLLOcvIAukA

Note what the honest comparison is: at this size the compressed output is actually a touch longer than the raw JSON — 176 characters against 168 — but it is 36% shorter than what would actually land in the URL, because the enemy is not the payload's information content — it is percent-encoding tripling every structural character. On larger, more repetitive inputs (and real-world JSON is nothing if not repetitive), the LZ dictionary kicks in properly and the ratio improves further. Compression is what makes the 8,000-character budget feel roomy instead of cramped.

Reading it back without trusting it

The decode path assumes the URL is hostile, or at least mangled. Links get truncated by email clients, "helpfully" rewritten by chat apps, and hand-edited by curious users. getUrlParam treats every failure as "use the default":

export function getUrlParam(key: string, defaultValue: string = ''): string {
  if (typeof window === 'undefined') return defaultValue;
  const params = new URLSearchParams(window.location.search);
  const raw = params.get(key);
  if (raw === null) return defaultValue;
  if (!raw.startsWith(COMPRESS_PREFIX)) return raw;
  try {
    const decompressed = decompressFromEncodedURIComponent(
      raw.slice(COMPRESS_PREFIX.length),
    );
    return decompressed ?? defaultValue;
  } catch {
    return defaultValue;
  }
}

There are four fallback layers stacked in those few lines. The typeof window guard makes the function safe during prerendering, where no URL exists — every ToolRunner route is rendered to static HTML at build time, and the tools must initialize cleanly in Node. A missing parameter returns the default. A value without the z. prefix is returned verbatim, which is both the fast path for short values and the backward-compatibility path for pre-compression links. And a z.-prefixed value that fails to decompress — whether lz-string returns null on garbage or throws outright — falls back to the default via ?? defaultValue and the surrounding try/catch. A corrupted link never crashes a tool; it just opens the tool fresh.

Cleaning up on the way out

The subtle bug in URL-synced state is leakage across client-side navigations. ToolRunner is a single-page app: navigating from the Cron Generator to the Regex Tester swaps components without a page load, and whatever ?expr=...&tz=... the cron page wrote is still sitting in the address bar. If nothing removes it, the regex page inherits meaningless parameters, and a user who copies the URL there shares cron state with a regex link. So the hook's unmount cleanup deletes exactly the keys it owns:

useEffect(() => {
  return () => {
    const url = new URL(window.location.href);
    for (const key of keysRef.current) {
      url.searchParams.delete(key);
    }
    if (url.toString() !== window.location.href) {
      window.history.replaceState(null, '', url.toString());
    }
  };
}, []);

The real implementation wraps this in a try/catch that deliberately swallows errors — in test environments and other contexts where URL manipulation is unavailable, a failed cleanup should never take the app down with it. It also clears any still-pending debounce timer, so a write scheduled 200ms before navigation cannot fire after the page is gone and resurrect parameters the cleanup just removed. Only the hook's own keys are touched; anything else in the query string — a UTM tag, another feature's parameter — passes through untouched.

The property I like most about this design is what it doesn't do. When you paste a JSON document, a regex test corpus, or an HTTP header dump into a ToolRunner tool, that content is never POSTed anywhere. There is no server to receive it, no database row with your input in it, and no retention policy to read, because there is nothing retained. The state exists in exactly two places: your browser's memory and, if you choose to share it, the link you copied. Sharing is an explicit act — you decide who sees the URL — rather than a side effect of using the tool.

That said, URLs have their own leakage surface, and I try to be honest about it. Query strings show up in browser history, in the access logs of the static host that serves the page, and — historically — in Referer headers sent to third parties, which is exactly the kind of thing response headers like Referrer-Policy exist to contain (I cover that family of headers in the HTTP Security Headers guide). Serving everything over HTTPS with HSTS keeps the full URL encrypted in transit, since the path and query string are inside the TLS envelope. The lz-string layer, for what it's worth, is compression, not encryption — z.-prefixed values are trivially reversible by design, and I would never suggest otherwise. The rule I follow, and the one I'd suggest to anyone using the tools: a shareable URL is exactly as sensitive as the state you put in it. For a cron schedule or a regex, that is nothing. For anything secret, don't put it in a tool that makes state shareable — and don't share the link.

Sources