mapsrv: speak HTTP/2, and measure what that is actually worth #33

Merged
art merged 1 commit from night/issue-5-http2 into main 2026-08-02 06:20:44 +00:00
Collaborator

Closes #5


Issue #5 — mapsrv is http/1.1

Branch: night/issue-5-http2 (commit b1c62d5, not pushed, not merged)
State: done

What changed

mapsrv/main.gomain() no longer calls http.ListenAndServe. There is now a
serve() method that builds an explicit http.Server with Protocols set to
HTTP/1.1 + HTTP/2 + unencrypted HTTP/2, plus two new optional config keys,
tls_cert / tls_key, which switch it to ListenAndServeTLS. Set only one of the
two and it dies at startup with a clear message rather than silently serving plaintext.

No new dependency: http.Protocols and the HTTP/2 server are stdlib as of Go 1.24
(go.mod says 1.25, the toolchain here is 1.26), so golang.org/x/net/http2 is neither
needed nor imported.

Docs: CLAUDE.md "Running the server", a new docs/performance.md section
("Serving: HTTP/2 moves the queue, it does not shorten it"), a commented block in
conf/mapsrv.yaml including the openssl one-liner for a local cert, and one sentence
in the README.md component list.

The finding, which is the actual deliverable

A browser only speaks HTTP/2 over TLS. None has ever implemented cleartext HTTP/2
and none is going to, so turning on h2c alone would have closed this issue while
changing nothing at all for the map page — http://localhost:8080 stays HTTP/1.1
however much the server supports. That is why the change includes TLS config keys
rather than just a one-line Protocols flip. Non-browser clients (curl
--http2-prior-knowledge, bench/, a reverse proxy) do get h2c on the plain listener.

It is worth about 9%, and the reason is instructive. Headless Chrome over CDP, 121
z12 tiles over Stockholm fired at once with fetch, cache-busted, same server on both
origins, alternating, 5 runs with the first discarded as a PostgreSQL warmup:

peak in flight stalled p50 on the wire p50 wall for 121 tiles
HTTP/1.1 (http://:8080) 6 527 ms 63 ms 1324 ms
HTTP/2 (https://:8443) 121 33 ms 541 ms 1202 ms

The six-connection limit is real and exactly six. But the two rows are the same total
in different places: over HTTP/1.1 115 tiles wait in the browser, over HTTP/2 all 121
reach the server and wait on the 8-connection pgx pool and on PostGIS instead.
ST_AsMVT was always the bottleneck (the section above it in docs/performance.md
measures a heavy tile at ~250 ms, 86% of the request), so multiplexing mostly relocates
the wait. It would be worth more over a real network with real RTT than over loopback.

The risk was cancellation, and it is fine. HTTP/2 makes the abandoned-tile problem
structurally worse — every abandoned tile now reaches a handler instead of dying in the
browser's queue. 121 tiles abandoned after 60 ms, then one tile we do want: 86 ms over
HTTP/2 against a 59 ms idle baseline (71/89 ms over HTTP/1.1). No regression. That is
entirely down to tiles.go passing r.Context(), which is now load-bearing for ~4×
as many requests; I noted that in CLAUDE.md next to the existing "do not drop this".

How it was verified

  • make build, make test (mapsrv handler tests pass), go vet, gofmt clean on the
    file I touched. (gofmt -l mapsrv also lists sprite.go — pre-existing, untouched,
    left alone.)
  • make restart + make smoke-test: OK, 221 bytes.
  • Plain listener: curl default → 1.1; curl --http2-prior-knowledge2 on
    /tiles/*, /style.json, /api/me, /sprite@2x.png, /fonts/Go%20Regular/0-255.pbf
    — every route family, and the gzip middleware works over h2 unchanged.
  • TLS listener on :8443 with a throwaway self-signed cert: openssl s_client -alpn h2,http/1.1 reports ALPN protocol: h2; curl negotiates HTTP/2 and --http1.1 still
    falls back cleanly.
  • Mismatched config (tls_cert without tls_key) exits with the intended error.
  • The two CDP benchmarks above. No database writes of any kind; no import was run and
    none is needed — this change does not touch the schema, the importer or the style.

Left undone / uncertain

  • The live mapsrv on :8080 was rebuilt from main and restarted at the end, so the
    running server is HTTP/1.1-only again until the branch is merged. make smoke-test OK.
  • No certificate is configured, so nothing about the running server changed today.
    conf/mapsrv.yaml ships the keys commented out and the box still serves plaintext
    on :8080, i.e. the map page in a browser is still HTTP/1.1. Enabling it is a decision
    for you: a self-signed cert means clicking through a browser warning on every fresh
    profile, which for ~9% on loopback may well not be worth it. The openssl line is in
    the config comment when you want it. If the map ever gets served over a real hostname
    with a real certificate, this now gives HTTP/2 for free.
  • bench/ was not taught to use HTTP/2. It would be a two-line change
    (Transport.Protocols), but the browser measurement answered the question and I did
    not want to widen the diff on a night run. Worth a small follow-up issue if you want
    the numbers reproducible from the repo's own tool rather than from a scratchpad script.
  • The pgx pool is now the only limit on concurrent tile work (MaxConnections: 8 in
    main.go), where before the browser's six connections were the tighter of two. I did
    not change it: 8 matches the core count and a tile is single-threaded CPU, so
    raising it would just move the queue again. Flagging it because the tuning question is
    now live in a way it was not before.
  • The headless-Chrome scripts live in this session's scratchpad, not in the repo. The
    method is described in docs/performance.md ("How to measure any of this") and the
    measurement is reproducible from it; note that --disable-gpu is fine for a raw
    fetch burst but kills MapLibre, as that section already warns.
Closes #5 --- # Issue #5 — mapsrv is http/1.1 **Branch:** `night/issue-5-http2` (commit `b1c62d5`, not pushed, not merged) **State:** done ## What changed `mapsrv/main.go` — `main()` no longer calls `http.ListenAndServe`. There is now a `serve()` method that builds an explicit `http.Server` with `Protocols` set to HTTP/1.1 + HTTP/2 + **unencrypted** HTTP/2, plus two new optional config keys, `tls_cert` / `tls_key`, which switch it to `ListenAndServeTLS`. Set only one of the two and it dies at startup with a clear message rather than silently serving plaintext. No new dependency: `http.Protocols` and the HTTP/2 server are stdlib as of Go 1.24 (go.mod says 1.25, the toolchain here is 1.26), so `golang.org/x/net/http2` is neither needed nor imported. Docs: `CLAUDE.md` "Running the server", a new `docs/performance.md` section ("Serving: HTTP/2 moves the queue, it does not shorten it"), a commented block in `conf/mapsrv.yaml` including the openssl one-liner for a local cert, and one sentence in the `README.md` component list. ## The finding, which is the actual deliverable **A browser only speaks HTTP/2 over TLS.** None has ever implemented cleartext HTTP/2 and none is going to, so turning on h2c alone would have closed this issue while changing nothing at all for the map page — `http://localhost:8080` stays HTTP/1.1 however much the server supports. That is why the change includes TLS config keys rather than just a one-line `Protocols` flip. Non-browser clients (curl `--http2-prior-knowledge`, `bench/`, a reverse proxy) do get h2c on the plain listener. **It is worth about 9%, and the reason is instructive.** Headless Chrome over CDP, 121 z12 tiles over Stockholm fired at once with `fetch`, cache-busted, same server on both origins, alternating, 5 runs with the first discarded as a PostgreSQL warmup: | | peak in flight | stalled p50 | on the wire p50 | wall for 121 tiles | |---|---|---|---|---| | HTTP/1.1 (`http://:8080`) | **6** | 527 ms | 63 ms | 1324 ms | | HTTP/2 (`https://:8443`) | **121** | 33 ms | 541 ms | **1202 ms** | The six-connection limit is real and exactly six. But the two rows are the same total in different places: over HTTP/1.1 115 tiles wait in the browser, over HTTP/2 all 121 reach the server and wait on the 8-connection pgx pool and on PostGIS instead. `ST_AsMVT` was always the bottleneck (the section above it in `docs/performance.md` measures a heavy tile at ~250 ms, 86% of the request), so multiplexing mostly relocates the wait. It would be worth more over a real network with real RTT than over loopback. **The risk was cancellation, and it is fine.** HTTP/2 makes the abandoned-tile problem structurally worse — every abandoned tile now reaches a handler instead of dying in the browser's queue. 121 tiles abandoned after 60 ms, then one tile we do want: 86 ms over HTTP/2 against a 59 ms idle baseline (71/89 ms over HTTP/1.1). No regression. That is entirely down to `tiles.go` passing `r.Context()`, which is now load-bearing for ~4× as many requests; I noted that in CLAUDE.md next to the existing "do not drop this". ## How it was verified - `make build`, `make test` (mapsrv handler tests pass), `go vet`, `gofmt` clean on the file I touched. (`gofmt -l mapsrv` also lists `sprite.go` — pre-existing, untouched, left alone.) - `make restart` + `make smoke-test`: OK, 221 bytes. - Plain listener: `curl` default → `1.1`; `curl --http2-prior-knowledge` → `2` on `/tiles/*`, `/style.json`, `/api/me`, `/sprite@2x.png`, `/fonts/Go%20Regular/0-255.pbf` — every route family, and the gzip middleware works over h2 unchanged. - TLS listener on :8443 with a throwaway self-signed cert: `openssl s_client -alpn h2,http/1.1` reports `ALPN protocol: h2`; curl negotiates HTTP/2 and `--http1.1` still falls back cleanly. - Mismatched config (`tls_cert` without `tls_key`) exits with the intended error. - The two CDP benchmarks above. No database writes of any kind; no import was run and none is needed — this change does not touch the schema, the importer or the style. ## Left undone / uncertain - **The live mapsrv on :8080 was rebuilt from `main` and restarted at the end**, so the running server is HTTP/1.1-only again until the branch is merged. `make smoke-test` OK. - **No certificate is configured, so nothing about the running server changed today.** `conf/mapsrv.yaml` ships the keys commented out and the box still serves plaintext on :8080, i.e. the map page in a browser is still HTTP/1.1. Enabling it is a decision for you: a self-signed cert means clicking through a browser warning on every fresh profile, which for ~9% on loopback may well not be worth it. The openssl line is in the config comment when you want it. If the map ever gets served over a real hostname with a real certificate, this now gives HTTP/2 for free. - **`bench/` was not taught to use HTTP/2.** It would be a two-line change (`Transport.Protocols`), but the browser measurement answered the question and I did not want to widen the diff on a night run. Worth a small follow-up issue if you want the numbers reproducible from the repo's own tool rather than from a scratchpad script. - **The pgx pool is now the only limit on concurrent tile work** (`MaxConnections: 8` in `main.go`), where before the browser's six connections were the tighter of two. I did **not** change it: 8 matches the core count and a tile is single-threaded CPU, so raising it would just move the queue again. Flagging it because the tuning question is now live in a way it was not before. - The headless-Chrome scripts live in this session's scratchpad, not in the repo. The method is described in `docs/performance.md` ("How to measure any of this") and the measurement is reproducible from it; note that `--disable-gpu` is fine for a raw `fetch` burst but kills MapLibre, as that section already warns.
Issue #5 asked whether HTTP/2 would unlock better concurrency when fetching a
lot of tiles. It does remove a real limit, and the limit is smaller than it
looks; both halves are worth writing down.

The change itself is small because net/http has done HTTP/2 since Go 1.24
without golang.org/x/net: main.go now builds an explicit http.Server with
Protocols set to HTTP/1.1 + HTTP/2 + unencrypted HTTP/2, so the plaintext
listener serves h2c to prior-knowledge clients (curl, bench/, a reverse proxy)
while HTTP/1.1 clients notice nothing. New optional tls_cert/tls_key config
keys switch to ListenAndServeTLS, where ALPN negotiates h2.

TLS is not an afterthought here: no browser has ever implemented cleartext
HTTP/2, so http://localhost:8080 is HTTP/1.1 whatever the server supports, and
the certificate is the only way the map page itself gets HTTP/2. Hence the keys
rather than just flipping h2c on and declaring victory.

Measured with headless Chrome over CDP, 121 z12 tiles fired at once at the same
server over both protocols, alternating, warmup discarded. HTTP/1.1 peaks at
exactly 6 requests in flight with a 527 ms median stall; HTTP/2 peaks at 121
with a 33 ms stall but 541 ms on the wire. Wall time 1324 -> 1202 ms, ~9%. The
queue moves from the browser onto the 8-connection pgx pool rather than
disappearing, because ST_AsMVT was always the bottleneck.

The risk worth checking was cancellation, since HTTP/2 delivers every abandoned
tile to a handler instead of leaving most of them in the browser's queue: 121
tiles abandoned after 60 ms, then a wanted tile in 86 ms against a 59 ms idle
baseline. No regression -- tiles.go passing r.Context() is what makes that true,
and it is now load-bearing for four times as many requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
art force-pushed night/issue-5-http2 from b1c62d5124 to d1fb31c414 2026-08-02 06:19:57 +00:00 Compare
art merged commit d1fb31c414 into main 2026-08-02 06:20:44 +00:00
art deleted branch night/issue-5-http2 2026-08-02 06:20:44 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!33
No description provided.