Follow

node-cache-warmer

Crawl a site after deploy so nobody hits a cold cache

After a deployment, the first visitor pays for the cold cache. This tool does that visit for them: it crawls a page, follows every link it finds, and tracks what it has already seen.

Configuration is environment variables — the entry page and the link selector — which made it easy to aim at different sites and stages.


FactDetail
BehaviourCrawls an entry page and all discovered links
Configuration`CRAWL_PAGE`, `CRAWL_LINKSELECTOR`
StackNode (crawler, seenreq)
StartedMarch 2018

What's in it

Selector-driven

The link selector decides what counts as a page to warm, so it adapts to different site structures.

Deploy-time step

Small enough to run as the last step of a release.

In detail

What is actually inside it

Selector as the interface

What counts as a page to warm is a CSS selector, so the same binary handles a blog, a shop and a documentation site without code changes.

A deploy step, not a service

Small enough to run as the last step of a release — the point was that the first visitor after a deploy should not pay for a cold cache.

DetailValue
Behaviourcrawl entry page + all matched links, deduplicated
Configuration`CRAWL_PAGE`, `CRAWL_LINKSELECTOR`
StartedMarch 2018

Technical notes

The decisions behind it

Why warm caches at all?
Because a cleared cache plus a traffic spike is a self-inflicted outage. Doing the crawl ourselves after each deploy turned that into a predictable, boring step.
Why deduplicate URLs?
Without seenreq, sites with tag pages and paginated archives turn into infinite loops. Deduplication is what makes it safe to point at a real site.

Under the hood

crawl.js, read literally

The whole tool is crawl.js: 72 lines, two runtime dependencies (crawler, seenreq), no dev dependencies and no tests. It seeds the queue with CRAWL_PAGE + "/", so the configured value has to stay a bare origin — a trailing slash produces a // entry URL.

Each response is parsed with Cheerio and $(CRAWL_LINKSELECTOR) picks the elements whose href becomes a candidate. A candidate survives only if it clears eleven substring tests: it must not point at another origin and must not contain javascript:, mailto:, file:, tel:, fax:, ?, typo3/ or fileadmin/. The "other origin" test is not a URL comparison — it is linkUrl.indexOf(PAGE) > -1, i.e. "does the configured origin appear somewhere in this string?"

Deduplication is a bare seenreq instance: seen.exists(PAGE + relLinkUrl) checks and records in the same call, hashing a normalised request into an in-memory object. crawler's own skipDuplicates is left at its false default, so this one call is the only thing standing between the run and a loop.

The crawler options set exactly two things: HTTP Basic auth and rateLimit: 300. Everything else is the library default — maxConnections: 10, timeout: 15000, retries: 3, retryTimeout: 10000. The rate limit is what you actually notice: measured against a local server, requests leave 277–301 ms apart, so throughput is about 3.3 pages per second and the ten connections are never exercised. When the queue drains, the process calls exit(0).

What happens when a crawled URL is not HTML?
crawler skips Cheerio injection for non-injectable content types and invokes the callback with response.$ undefined, after logging response body is not HTML, skip injecting. The extraction block is guarded with typeof $ === "function", but the log line right after it is not: it calls $("title").text(). One link to a PDF, image or JSON endpoint therefore throws TypeError: $ is not a function inside the crawl callback and kills the process with exit code 1. Reproduced with a page linking to an application/pdf response — the run died after three URLs.
What happens if the credentials are unset?
The auth object is passed unconditionally, with user/pass read straight from CRAWL_USER/CRAWL_PASSWORD. With both undefined, request throws no auth mechanism defined for every URL. crawler retries three times, ten seconds apart, then logs the error; the queue drains and the process still exits 0. A misconfigured run spends ~30 seconds warming nothing and reports success to whatever invoked it.
Why can it never warm a URL that uses a query string?
The filter rejects any href containing ? before it is ever queued, so ?page=2, filter and sort links and TYPO3/Extbase plugin URLs are silently skipped. The fragment strip (#) and the leading-slash fix run only for links that got past this gate. The corollary is that the warmer can only ever reach URLs the static HTML already exposes.
Does it execute JavaScript or respect robots.txt?
Neither. Cheerio parses the server's HTML, so links a client-side script inserts — and anything revealed only after a session login — are invisible. There is no robots.txt fetch, and the requests carry no User-Agent at all (verified: only host, authorization, accept-encoding, connection).

A deploy step in a TYPO3 image

what the ignore-list and the Dockerfile give away

The two path exclusions, typo3/ and fileadmin/, pin the target down: a TYPO3 site. typo3/ is the backend entry point and fileadmin/ the uploads directory, so skipping them kept the crawl out of the CMS internals and away from binary assets. The point of running it was to make the front-end cache hot the moment a release finished, so it ran as a one-shot at the end of a deploy, not as a service.
Runs from an image, not a runtime

FROM node:carbon, matching .nvmrc's lts/carbon (Node 8, long past EOL). npm install rather than npm ci; the --only=production line is left commented out and node_modules is excluded by .dockerignore. The image existed so the crawl could be the last step of a release without a Node runtime on the deploy host.

The pipeline lived outside the repo

There is no CI config anywhere in the tree — build and trigger sat in the surrounding pipeline, and the Dockerfile was the contract: build it, run it once with CRAWL_PAGE, CRAWL_LINKSELECTOR and credentials.

Only HTTP Basic gets in

The same credentials are attached to every request, so a staging site behind htpasswd warms cleanly. A cookie session or a form login does not — those pages simply never appear in the crawl, and nothing reports that they were missed.

A scaffold the README never escaped

README.md is a single heading; the operating knowledge was never written down. The eslint config targets ecmaVersion: 5 and the stylelint config loads stylelint-scss plugins for a repo that contains no stylesheets — leftovers from the project template it grew out of.