Poll — URL-encoded survey state without pushState

Three questions, five choices each. Every radio click is a full HTTP redirect so Apache logs the state; submit leaves the site. URL form /poll/552 (hex, one digit per question) and the 2010 legacy form /poll/000010000100010 (15-bit one-hot) both parse — try either.

The 2010 shape

The primary source: p.wal.sh/poll/000010000100010.html.

Three questions. Five choices each. 5 × 5 × 5 = 125 combinations. Each combination gets its own pre-generated static HTML file, whose filename encodes the selected answers as 15-bit one-hot binary:

/poll/{q1 5 bits}{q2 5 bits}{q3 5 bits}.html

The URL 000010000100010.html decodes as:

  • 00001 — q1 = choice 5
  • 00001 — q2 = choice 5
  • 00010 — q3 = choice 4

The radio buttons on the page do not wire up. Each rendered file simply shows one selection. "Submit" navigates away.

What breaks:

  • URLs for unlisted combinations 404. (The rendered set is exactly the 125 pre-generated files.)
  • Adding a fourth question means regenerating 5^4 = 625 files.
  • Changing any question's choice count reshapes every filename.
  • The 15 characters are opaque to a reader — no way to sanity-check a URL by eye.

Three encodings compared

The same state — three questions of five choices — under three shapes:

Encoding 3q×5c URL +1 question Human-scannable Ceiling
One-hot binary (2010) 000010000100010 +5 chars No 5 choices per question
Hex-index per question 552 +1 char Yes 15 choices per question
Bit-packed integer 7d (= 125) new radix No radix × question count

One-hot binary. The 2010 shape. Every question is a 5-bit field with exactly one bit set. Fifteen characters for the three-question form. Every new question adds five characters. Regular, but you can't read it — to know which choice is set you count zeros. Cache-friendly (each URL is a distinct static file) but that's also why adding a fourth question costs 625 files.

Hex-index per question. One hex character per question, 0 for unanswered, 1..~f~ for choices 1..15. Three characters for the three- question form. Adding a question adds one character. The URL 552 reads "choice 5, choice 5, choice 2" at a glance; 0f0 reads "unanswered, choice 15, unanswered". Ceiling is 16 choices per question (0 reserved for unanswered), well past where a radio-button UI stops being the right UI anyway.

Bit-packed integer. Pack the whole state as a single base-N integer: 1*25 + 5*5 + 5 = 55 for state (1, 5, 5), then render as hex, base36, whatever. Two characters at 3q×5c. Most compact. Two problems: the encoding depends on knowing the radix (5), so adding a question or changing a choice count invalidates every existing URL; and a URL like 7d is unreadable without a decoder in your head.

Recommendation: hex-index. Fixed alignment (position N in the URL is always the answer to question N), append-only extension (adding a question appends a character, never rewrites existing positions), and a reader can verify by eye that the URL matches what they picked.

Why we didn't use pushState

The obvious modern shape is history.pushState + a fragment URL: click a radio, update #552 in the address bar, no reload, no server round-trip. That was the initial design. It is the wrong tool for this joke.

The fragment (#552) is client-only. Browsers strip it before sending the HTTP request. The server never sees which radio the visitor picked; Apache's access log never records a state change. Whether someone answered "5, 5, 2" or "1, 1, 1" is invisible below the wire. The 2010 version, which pre-generated 125 static HTML files and required a full page navigation per selection, had more server visibility than a modern pushState SPA. That is funny in a way that has to be preserved.

Two ways to keep server visibility:

  • Query params/poll/?s=552. The path stays constant; the query string is logged. Easy to add, ugly to look at, breaks the aesthetic parity with the 2010 URLs (/poll/000010000100010.html).
  • Path rewrite/poll/552 served by an Apache rewrite to the underlying SPA HTML. The path is what gets logged, and it looks exactly like the 2010 URLs did (minus the .html).

Path rewrite wins on aesthetics and log symmetry. The .htaccess gains four lines:

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule ^poll/([0-9a-f]{3}|[01]{15})/?$ /tools/poll-pushstate/index.html [L]
    RewriteRule ^poll/?$                        /tools/poll-pushstate/index.html [L]
</IfModule>

Both the hex form and the 2010 binary form resolve to the same underlying HTML. Anything else 404s (/poll/garbage, /poll/1234) — the regex is the validator. Curling all three confirms:

$ curl -sI https://wal.sh/poll/552            | head -1
HTTP/2 200
$ curl -sI https://wal.sh/poll/000010000100010 | head -1
HTTP/2 200
$ curl -sI https://wal.sh/poll/garbage         | head -1
HTTP/2 404

The browser-side code becomes shorter than the pushState version, because there is no state to reconcile between the URL and an in-memory atom — the URL is the state, and every selection is a full page reload:

(def ^:private pathname-regex #"^/poll/([0-9a-f]{3}|[01]{15})/?$")

(defn- parse-pathname []
  (let [p (.-pathname js/location)
        m (re-matches pathname-regex p)
        s (when m (second m))]
    (cond
      (nil? s)         (core/default-state)
      (= 15 (count s)) (core/decode-legacy s)
      :else            (core/decode s))))

(defn- on-radio-change [ev]
  (let [t         (.-target ev)
        qkey      (keyword (.getAttribute t "data-qkey"))
        choice    (js/parseInt (.getAttribute t "data-choice") 10)
        new-state (assoc (parse-pathname) qkey choice)]
    (set! (.-href js/location) (str "/poll/" (core/encode new-state)))))

The pure encode/decode/valid? sits in a .cljc core namespace with clojure.test + test.check property tests. Only the adapter changed shape between hash-driven and pathname-driven; the reducer contract stayed the same.

What Apache sees

Every click produces one access-log line. A visitor who works through the survey and submits will trace a path like:

71.234.216.215 ... "GET /poll/500 HTTP/2.0" 200
71.234.216.215 ... "GET /poll/550 HTTP/2.0" 200
71.234.216.215 ... "GET /poll/552 HTTP/2.0" 200
71.234.216.215 ... "GET / HTTP/2.0" 302 (submit → https://google.com)

Server-log analysis is a proper first-class citizen here in a way it cannot be for a pushState SPA. The trade — a full HTTP round-trip per radio click, plus a JS parse and re-render on every load — is the inefficiency the 2010 version had built in by accident of technology. Sixteen years later, we do it on purpose.

What happens when you press submit

The submit button sets window.location.href = "https://google.com". There is no backend, no data collection, no analytics beacon. The 2010 version did the same thing. Sixteen years of engineering the encoding of a URL whose only registered event is "leaves the site."

Related