perf: cache, compress, and precompute API responses - #3
Open
ashwinn-si wants to merge 2 commits into
Open
Conversation
Every list endpoint re-derived its filter values inside the `.filter()` callback, so a query like `?created_gte=2024-01-01` parsed the same date string 200 times per request, and `?city=lax` re-ran toUpperCase() per row. Normalize once before the scan instead. Date filters now compare numeric timestamps rather than allocating a Date per comparison. Responses are unchanged: all 24 filter/pagination combinations exercised byte-for-byte identical output before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dataset is generated once at boot from fixed seeds, so a given URL returns identical bytes for the life of a deployment — but nothing told clients or the CDN that, and every response was sent uncompressed. - Add Cache-Control to the API (s-maxage 24h) and the HTML pages, with a version-derived ETag so replicas agree and restarts don't invalidate CDN entries. Setting the ETag up front also skips express's per-response body hash and lets revalidation return a bodyless 304. - Strip those headers at writeHead time on any non-200, so a 500 can never get pinned in a shared cache. - Enable compression. API payloads shrink 75-88%, the HTML pages 99%. - Serialize the swagger spec and the OpenCollection docs once at boot. /docs was re-reading and re-parsing a 53 KB YAML file on every request. - Embed those payloads compact rather than pretty-printed, with '<' escaped so a value can't close the script tag early. - Move express.static below the routers so API requests stop paying for a filesystem lookup on the way to their handler. - Batch PostHog events instead of one HTTPS request per capture. Also fixes three latent issues found on the way: js-yaml was required but never declared as a dependency (it resolved only by hoisting), the Dockerfile healthcheck probed a /health route that did not exist, and the swagger glob resolved from the working directory rather than the source. Measured, localhost, keep-alive: /docs 1.477 -> 0.388 ms, /spec 0.847 -> 0.461 ms, /hotels?per_page=50 0.199 -> 0.174 ms uncompressed. Compression costs 0.100 ms of CPU there and saves 32.6 KB, which is 2.6 ms of transfer on 100 Mbps and 26 ms on 4G. Revalidation costs 0.118 ms and no body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The dataset here is generated once at boot from fixed seeds, so a given URL returns identical bytes for the life of a deployment. Nothing communicated that to clients or a CDN, and every response went out uncompressed — so each request paid full origin cost and full transfer cost for data that never changes.
This PR makes responses cacheable and compressed, moves per-request work to boot time, and removes redundant work from the filter loops. No response body changes.
Changes
Caching and compression
Cache-Controlon the API (s-maxage=86400) and on the HTML pages, with an ETag derived from the app version so replicas agree on validators and a restart doesn't invalidate CDN entries. Setting the ETag up front also skips express's per-response body hash and lets revalidation return a bodyless 304.writeHeadtime on any non-200, so a 500 can never get pinned in a shared cache.compressionmiddleware. API payloads shrink 75–88%, the HTML pages 99%.Work moved off the request path
/docswas callingfs.readFileSync+yaml.loadon a 53 KB YAML file on every request. Parsed once at boot./specre-ranJSON.stringify(spec, null, 2)per render. Serialized once at boot.<escaped so a value can't close the<script>tag early.express.staticmoved below the routers — every API request was doing a filesystem lookup againstsrc/public/on its way to a handler.flushAt: 1, flushInterval: 0, meaning one outbound HTTPS request per captured event. Now batched; the existing shutdown handler flushes the queue.Filter loops
.filter()callback.?created_gte=2024-01-01parsed the same date string 200 times per request;?city=laxre-rantoUpperCase()per row. Normalized once before the scan, and date filters now compare numeric timestamps instead of allocating aDateper comparison.Latent bugs fixed along the way
js-yamlwas required insrc/index.jsbut never declared as a dependency — it resolved only by hoisting out ofswagger-jsdoc's tree, so any dependency bump would have broken/docsin production.HEALTHCHECKprobes/health, which did not exist. Containers were permanently unhealthy. Added the route.Verification
Captured responses for 24 filter and pagination combinations from the current
main, then replayed the same requests against this branch. Byte-identical across all 24 — covering every domain, every filter type (string, numeric, date-range, array, full-text search), and both single and combined filters.Also confirmed: all routes return 200,
/specstill emits all 36 paths after the glob change, both embedded JSON payloads parse, and a genuine 500 getsCache-Control: no-storewhile its 200 siblings stay cacheable.Measurements
Localhost, keep-alive, 300 requests per path:
/docs/spec/hotels?per_page=50(uncompressed)On compression, stated plainly: localhost is the worst case for it. Serving
/hotels?per_page=50gzipped costs 0.100 ms of CPU and measures slower over loopback (0.199 → 0.274 ms) because there's no network to save. What it buys is 32.6 KB per response — 2.6 ms of transfer on 100 Mbps, 26 ms on 4G, 130 ms on 3G. For a public API that's a 26×–1300× net win, but it is a real trade and worth naming rather than burying.Payload sizes:
/hotels?per_page=50/billing/invoices(filtered)/spec/docsThe larger point is the caching, which these numbers don't capture: with
Cache-Controlset, repeat traffic is served by the CDN and never reaches origin at all.Notes for reviewers
Two things worth a deliberate decision rather than a rubber stamp:
s-maxage=86400means a deploy takes up to 24h to fully propagate through a CDN unless you purge on release. Lower it if you'd rather not add a purge step.package.jsonversion. Bump it on any change to the generated data, or warm clients will keep serving stale bodies from cache. If that's too easy to forget, an alternative is hashing the generated datasets at boot — costs a little startup time and gives up cross-replica ETag stability.Out of scope
/billing/paymentsreturns 500 on every request — a null dereference ingeneratePayment, likely from the lazyrequireused to break the payments/invoices circular dependency. This reproduces onmainand is untouched here; it's what surfaced the error-caching guard during testing. Worth a separate PR.