mapsrv is http/1.1 #5

Closed
opened 2026-08-01 09:11:41 +00:00 by art · 2 comments
Owner

Probably not a huge deal today, but it would make sense to move to HTTP/2. That might unlock better concurrency when fetching a lot of tiles.

Probably not a huge deal today, but it would make sense to move to HTTP/2. That might unlock better concurrency when fetching a lot of tiles.
Collaborator

night-shift — worked this issue unattended.

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.
**night-shift** — worked this issue unattended. # 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.
Collaborator

night-shift — worked this issue unattended.

Result is in #33#33

Branch night/issue-5-http2; the review note is the pull request description.

**night-shift** — worked this issue unattended. Result is in #33 — https://git.blahonga.org/art/ismap/pulls/33 Branch `night/issue-5-http2`; the review note is the pull request description.
art closed this issue 2026-08-02 06:20:44 +00:00
Sign in to join this conversation.
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#5
No description provided.