Skip to main content
Docs menu

Call the worker HTTP API

Last updated Edit this page View as Markdown

Optimizer worker HTTP API for mod_pagespeed 2.1: health, stats, Prometheus metrics, cache management, live config and WebSocket streaming, all via curl.

On this page

The worker exposes an HTTP/1.1 API for monitoring, cache management, configuration, and browser-based analysis. The web console is built on this API, so anything the console shows you can also read or drive yourself. Every endpoint documented here works with curl or any HTTP client.

Base URL

The API listens on 127.0.0.1:9880 by default. Configure with --api-port. Set port to 0 to disable the HTTP API entirely.

http://127.0.0.1:9880/v1/...

All endpoints are prefixed with /v1/. The server supports HTTP/1.1 only (no HTTP/2). Keep-alive connections are supported.

Server limits

ParameterDefaultFlag
Max connections128--max-connections
Request timeout10s-
Max request body64 KB-
Max URL length8192-
Max header bytes64 KB-

Transports

The API is off until you enable a transport, and there are two.

--api-socket [PATH] serves HTTP/1.1 over a unix socket (default /run/pagespeed-optimizer/api.sock, mode 0660 pagespeed:pagespeed). This is the recommended local transport: filesystem permission is the credential, so a peer in group pagespeed needs no token — for reads and for cache purge alike — and the worker binds no TCP port at all. A connection accepted on the socket is an authenticated peer; a token configured alongside it does not reimpose a bearer requirement.

Peers on the socket must still send X-Requested-With: XMLHttpRequest on mutating requests (POST/PATCH). Reverse-proxying the console onto this socket is a supported topology, and through such a proxy a browser can reach it cross-origin — with no credential in the chain — so that header is the control that keeps a cross-origin POST from purging your cache. Reads need nothing.

curl --unix-socket /run/pagespeed-optimizer/api.sock http://localhost/v1/stats

The --api-socket transport is also the surface the web server module’s unified console reads: its worker panels proxy GET /v1/health, GET /v1/stats, and GET /v1/cache/cooldowns over this socket. The console panels require a 2.1 worker or later; against an older or unreachable worker the panel reports it as such instead of failing the console.

--api-port PORT binds TCP, 127.0.0.1 by default.

Authentication

A token is set with the PAGESPEED_API_TOKEN environment variable (preferred — --api-token puts the credential on the command line, where any local user can read it out of /proc/<pid>/cmdline). With a token configured, every endpoint except /v1/health and the /console/ static bundle (GET/HEAD only) requires a Bearer header.

curl -H "Authorization: Bearer <your-token>" http://localhost:9880/v1/stats

The invariant

Remote is never unauthenticated, and unauthenticated is never remote. The worker checks this while parsing its configuration and refuses to start rather than serve a combination that breaks it. Each refusal names the flag that would make the requested posture legal.

TransportTokenExtra flagResult
unix socketeither—normal — no token is asked for
TCP loopbackpresent—normal
TCP loopbackabsent--api-no-authallowed, warning banner at startup
TCP loopbackabsent—refuses to start
TCP non-loopbackpresent--api-allow-remoteallowed, warning banner
TCP non-loopbackpresent—refuses to start
TCP non-loopbackabsentanyrefuses to start

Also refused at startup: --api-socket together with --api-port (one transport only), and an --api-bind that is not an IPv4 literal (localhost and ::1 included — the API binds IPv4 only).

Authentication modes

ModeBehavior
Unix socketAll endpoints open to peers in group pagespeed
Token configured (TCP)All endpoints require auth (except /v1/health and the /console/ static bundle)
Token + --api-read-openAn explicit allow-list of GET routes + the stats/events WebSocket streams open (except the log: GET /v1/logs, /v1/ws/logs), POST/PATCH require auth
No token + --api-no-authAll endpoints open — TCP loopback only

In read-open mode (--api-read-open), an explicit allow-list of GET endpoints and WebSocket streams is accessible without authentication. Concretely: the cached-URL inventory (GET /v1/cache/urls), per-URL variant detail and content (GET /v1/cache/alternates, /select, /content) — which is also where the origin hosts and content types behind each cached URL are visible — the active cooldown table (GET /v1/cache/cooldowns), overall stats/metrics/config (GET /v1/stats, /v1/metrics, /v1/config), and the live /v1/ws/stats and /v1/ws/events WebSocket streams. This enables exposing the console publicly as a read-only dashboard while keeping mutating operations (config changes, cache purge) behind the token. Auth for WebSocket is sent as the first message (see WebSocket Endpoints). The one exception is the optimizer log: log lines carry the URLs, origin hosts and error text of every site the optimizer serves, so GET /v1/logs still requires the token on TCP, and the /v1/ws/logs stream requires it too — send it as the first message after the handshake (see WebSocket Endpoints), since a browser cannot carry the token on the handshake itself.

--api-read-open is an explicit allow-list, not an opt-out. In earlier releases an absent token opened reads as a side effect; it no longer does, and an unset token now fails closed on every endpoint that is not /v1/health or the /console/ static bundle. The allow-list is also closed by default for anything new: a GET route added in a later release stays behind the token under read-open unless it is deliberately added to the list, so the read-only surface never grows silently.

Because read-open still exposes real operational data about your sites, only enable it behind a reverse proxy that performs its own authentication (IP allow-listing, a login prompt, etc.) — never expose it directly to the open internet.

Error responses

When auth fails, the API returns:

  • 401 UNAUTHORIZED — Missing Authorization header or wrong scheme
  • 403 FORBIDDEN — Invalid token
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication required"
  }
}

CORS

Set --cors-origin to enable cross-origin requests. An empty value (default) disables CORS headers entirely. Supports a specific origin or * for wildcard. When auth is configured, the server reflects the actual origin (not *) so browser credentials work. Preflight OPTIONS requests return CORS headers with status 204.


Health and status

GET /v1/health

Health check and readiness probe. No authentication required. Designed for Kubernetes liveness probes, Docker HEALTHCHECK, and load balancer health checks.

curl http://localhost:9880/v1/health

Response:

{
"status": "ok",
"version": "2.0.42",
"uptime_seconds": 3421,
"connections": {
  "active": 2,
  "max": 32
},
"inflight": 3,
"ready": true
}
FieldDescription
statusAlways "ok" when the server is running
versionWorker build version
uptime_secondsSeconds since worker start
connections.activeCurrent HTTP connections
connections.maxConfigured maximum
inflightThread pool tasks in progress
readytrue when inflight < num_threads (capacity available)

GET /v1/stats

Full worker statistics as JSON. Covers notifications, variant generation, errors, cache state, processing times, content analysis, and more.

curl http://localhost:9880/v1/stats

Response:

{
  "notifications": {
    "received": 4821,
    "skipped_dedup": 1200,
    "skipped_inflight": 42
  },
  "variants": {
    "written": 2106,
    "proactive": 1580,
    "gzip": 840,
    "brotli": 840
  },
  "errors": {
    "total": 7,
    "origin_misconfiguration": 2
  },
  "cache": {
    "entries": 3412,
    "size_bytes": 268435456
  },
  "by_type": {
    "html": { "count": 120, "time_us": 450000 },
    "css": { "count": 340, "time_us": 180000 },
    "js": { "count": 280, "time_us": 150000 },
    "image": { "count": 1366, "time_us": 28500000 }
  },
  "by_format": {
    "webp": 580,
    "avif": 490,
    "jpeg": 180,
    "png": 116
  },
  "content_analysis": {
    "photo": 800,
    "screenshot": 120,
    "illustration": 300,
    "noisy": 50,
    "denoised": 30
  },
  "ssimulacra2": {
    "checks": 1200,
    "reencodes": 45,
    "avg_score_x100": 7200
  },
  "learned_quality": {
    "predictions": 1100,
    "fallbacks": 15
  },
  "html_assembly": {
    "complete": 110,
    "skipped": 10,
    "css_aborted": 3
  },
  "alternates": {
    "writes": 5000,
    "write_failures": 2
  },
  "selector_invocations": 12000,
  "cache_read_retries": 5,
  "cache_read_failures": 1,
  "started_at_ms": 1759230000000, "uptime_seconds": 3600,
  "svg": {
    "candidates_evaluated": 500,
    "candidates_rejected": 350,
    "vectorized": 120,
    "fidelity_rejected": 10,
    "size_rejected": 15,
    "path_count_rejected": 5,
    "written": 90,
    "bytes_saved": 2048000,
    "vectorize_time_us": 450000,
    "served": 75
  },
  "policy": {
    "computed": 85,
    "async_css_enabled": 60,
    "script_deferral_enabled": 45
  },
  "thread_pool": {
    "inflight": 3,
    "size": 8
  },
  "connections": {
    "active": 2,
    "max": 32
  },
  "serve_savings": {
    "html": { "original_bytes": 4200000, "optimized_bytes": 3990000, "hits": 120,
              "by_encoding": { "identity": { "hits": 120, "bytes": 3990000 },
                               "gzip": { "hits": 0, "bytes": 0 },
                               "br": { "hits": 0, "bytes": 0 } } },
    "css": { "original_bytes": 880000, "optimized_bytes": 540000, "hits": 340,
             "by_encoding": { "identity": { "hits": 340, "bytes": 540000 },
                              "gzip": { "hits": 0, "bytes": 0 },
                              "br": { "hits": 0, "bytes": 0 } } },
    "js": { "original_bytes": 1300000, "optimized_bytes": 910000, "hits": 280,
            "by_encoding": { "identity": { "hits": 280, "bytes": 910000 },
                             "gzip": { "hits": 0, "bytes": 0 },
                             "br": { "hits": 0, "bytes": 0 } } },
    "image": { "original_bytes": 52000000, "optimized_bytes": 7800000, "hits": 1366,
               "by_encoding": { "identity": { "hits": 1366, "bytes": 7800000 },
                                "gzip": { "hits": 0, "bytes": 0 },
                                "br": { "hits": 0, "bytes": 0 } } }
  },
  "serve_savings_by_host": {
    "hosts": [
      { "host": "www.example.com", "hits": 1200,
        "original_bytes": 41000000, "optimized_bytes": 9000000 },
      { "host": "static.example.com", "hits": 900,
        "original_bytes": 17000000, "optimized_bytes": 3300000 }
    ],
    "limit": 32,
    "other": { "hits": 6, "original_bytes": 180000, "optimized_bytes": 60000 }
  },
  "verdicts": {
    "css": { "already_optimal": { "count": 4, "bytes": 210000 } },
    "js": { "already_optimal": { "count": 1, "bytes": 88000 } },
    "image": { "already_optimal": { "count": 40, "bytes": 3100000 } }
  },
  "serve_classes": {
    "optimized": 1366,
    "original_cold": 40,
    "original_pending": 12,
    "original_declined": 0,
    "original_skew": 0,
    "notify_suppressed": 3,
    "class_unrecognized": 0,
    "flags_unrecognized": 0
  }
}

Key fields:

FieldDescription
notifications.receivedTotal notifications from nginx
notifications.skipped_dedupSkipped because variant already exists
notifications.skipped_inflightSkipped because URL is already being processed
notifications.dedup_healedAlready processed URLs with nothing servable left in the cache; processed again
notifications.missing_copy_healedAlready optimized stylesheets and scripts whose optimized copy was gone from the cache while their other copies remained; processed again, counted when the re-processing starts
notifications.dedup_heal_rate_limitedSkipped because the URL was processed again by one of the two heals above less than 10 seconds before (per URL and client class)
variants.writtenTotal optimized variants written to cache
variants.proactiveSibling variants (other format/viewport/density combinations)
variants.gzip / brotliPre-compressed text variants
errors.origin_misconfigurationOrigin responses that prevent optimization (e.g., no-store)
by_type.*.time_usCumulative processing time in microseconds
ssimulacra2.avg_score_x100Average visual quality score (7200 = 72.00)
learned_quality.predictionsML model quality predictions made
learned_quality.fallbacksTimes ML prediction was overridden by binary search
svg.bytes_savedCumulative bytes saved by SVG vectorization
policy.computedOptimization policies computed from browser profiles
policy.async_css_enabledTimes async CSS was enabled by policy
policy.script_deferral_enabledTimes script deferral was enabled by policy
serve_savings.*.original_bytesOrigin bytes for worker-processed HITs of this type
serve_savings.*.optimized_bytesBytes served from cache for those HITs
serve_savings.*.hitsOptimized HITs served, per content type
serve_savings.*.by_encoding.<enc>.hits / .bytesThe same serves split by the transfer encoding actually served: identity, gzip, br
serve_savings_by_host.hosts[]Up to limit (32) hosts, drawn from the first 64 distinct hosts seen since the optimizer started, ordered by optimized HITs (most first): host, hits, original_bytes, optimized_bytes
serve_savings_by_host.otherEvery other HIT: the remaining hosts, and HITs the front end recorded without a host
verdicts.*.already_optimal.count / .bytesEntries judged already optimal (no smaller variant produced) and their original bytes; counted once per entry, per worker restart
started_at_ms / uptime_secondsWhen this worker process started (epoch milliseconds) and how long it has been up
serve_classes.optimizedServes answered from a worker-produced alternate
serve_classes.original_*Serves of origin bytes, split by cause (cold, pending, declined, skew)
serve_classes.notify_suppressedOptimizer notifications suppressed by front-end dedup/cooldown
serve_classes.*_unrecognizedServe-class writes dropped (class) or partially ignored (flags)

serve_savings and serve_classes are present when serve-time statistics are available (the shared .pagespeed-serve-stats file). The serve_savings byte counters are measured by the serving front end (nginx, the ASP.NET middleware, or the module) as content is served, so they reflect realized bandwidth savings and are distinct from the worker-side by_type counters above. serve_classes partitions the serves a front end judged optimizable into exactly one class each; today only the module classifies serves, so under another front end the block reads all-zero — which means “not instrumented”, not “nothing happened”.

serve_savings_by_host attributes the same HITs to the host each response was served for, when the front end names one. The optimizer reports a host under its host name: ASCII letters are lowercased, and a :port (1–5 digits) and one trailing . are dropped, so WWW.Example.com:8443 and www.example.com. are both reported as www.example.com. What is left must be 1–127 bytes with at least one letter or digit, made of letters, digits, ., - and _ (a DNS name or an IPv4 address such as 10.1.2.3), or an IPv6 literal in brackets of hexadecimal digits, : and . ([2001:db8::1]; an IPv6 address without brackets is not accepted). Any other value — empty, longer, containing any other character, with a malformed bracket, or still ending in . (www.example.com..) — is never stored or reported: that HIT counts under other. The optimizer keeps a row for each of the first 64 distinct hosts it sees after it starts and reports up to 32 of them, ordered by HITs; a host seen after those 64 counts under other until the optimizer restarts. The hosts and other together add up to the serve_savings totals.

GET /v1/metrics

Worker statistics in Prometheus text exposition format. Suitable for direct Prometheus scraping.

curl http://localhost:9880/v1/metrics

Response (Content-Type: text/plain; version=0.0.4; charset=utf-8):

# HELP pagespeed_notifications_total Total notifications received.
# TYPE pagespeed_notifications_total counter
pagespeed_notifications_total 4821
# HELP pagespeed_variants_written_total Total variants written to cache.
# TYPE pagespeed_variants_written_total counter
pagespeed_variants_written_total 2106
# HELP pagespeed_cache_entries Current number of cache entries.
# TYPE pagespeed_cache_entries gauge
pagespeed_cache_entries 3412
...

All counters and gauges from /v1/stats are exposed as Prometheus metrics, including SVG stats, learned quality, content analysis, and browser analysis counters.

Serve savings per host are pagespeed_host_hits_served_total, pagespeed_host_original_bytes_served_total and pagespeed_host_optimized_bytes_served_total, labelled host with the host names serve_savings_by_host reports (the same host-name rule); the rest is host="(other)", a value no host name can take.


Configuration

GET /v1/config

Returns the full worker configuration.

curl http://localhost:9880/v1/config

Response:

{
  "jpeg_quality": 85,
  "webp_quality": 75,
  "avif_quality": 60,
  "mobile_width": 480,
  "tablet_width": 768,
  "desktop_width": 0,
  "gzip_level": 6,
  "brotli_level": 6,
  "disable_async_css": false,
  "disable_script_deferral": false,
  "svg_mode": "detect",
  "svg_candidacy_threshold": 50,
  "svg_max_pixels": 65536,
  ...
}

All worker configuration fields are included. See the Configuration Reference for field descriptions.

PATCH /v1/config

Update hot-reloadable configuration fields at runtime. Only fields included in the request body are changed. Changes are persisted to disk alongside the cache volume.

curl -X PATCH http://localhost:9880/v1/config \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"jpeg_quality": 80, "svg_mode": "auto"}'

Request body: JSON object with one or more config fields to update.

Response:

{
  "applied": {
    "jpeg_quality": 80,
    "svg_mode": "auto"
  },
  "rejected": {},
  "warnings": [],
  "config": { ... }
}
FieldDescription
appliedFields that were accepted and applied
rejectedFields that failed validation, with error messages
warningsNon-fatal warnings about the applied configuration
configFull configuration after applying changes

Fields that fail validation are rejected individually. Valid fields in the same request are still applied. All boolean toggle fields (disable_async_css, disable_script_deferral, etc.) are hot-reloadable via PATCH /v1/config.

{
  "applied": {
    "jpeg_quality": 80
  },
  "rejected": {
    "jpeg_quality_invalid": "unknown field"
  },
  "warnings": [],
  "config": { ... }
}

Cache operations

GET /v1/cache/urls

Paginated list of all cached URLs. The worker tracks URLs in memory as they are processed.

curl "http://localhost:9880/v1/cache/urls?offset=0&limit=50"

Query parameters:

ParameterTypeDefaultDescription
offsetint0Skip this many entries
limitint100Max entries to return (capped at 1000)
hostnamestring-Filter by hostname

Response:

{
  "urls": [
    {
      "url": "/images/hero.jpg",
      "hostname": "example.com",
      "alternate_count": 12
    },
    {
      "url": "/style.css",
      "hostname": "example.com",
      "alternate_count": 4
    }
  ],
  "offset": 0,
  "limit": 50,
  "next_offset": 50,
  "has_more": true,
  "total": 342
}

GET /v1/cache/alternates

List all stored variants (alternates) for a URL. Each alternate represents one optimized version: a specific combination of image format, viewport, pixel density, Save-Data preference, and transfer encoding.

curl "http://localhost:9880/v1/cache/alternates?url=/images/hero.jpg&hostname=example.com"

Query parameters:

ParameterTypeRequiredDescription
urlstringYesThe URL path
hostnamestringNoHostname for cache key

Response:

{
  "url": "/images/hero.jpg",
  "hostname": "example.com",
  "count": 5,
  "alternates": [
    {
      "alternate_id": 8,
      "size": 145200,
      "hit_count": 42,
      "is_sentinel": false,
      "mask": {
        "raw": 8,
        "format": "original",
        "viewport": "desktop",
        "density": "1x",
        "save_data": false,
        "encoding": "identity"
      },
      "format": "original",
      "viewport": "desktop",
      "density": "1x",
      "save_data": false,
      "encoding": "identity",
      "content_type": "image",
      "origin_content_type": "image/jpeg",
      "flags": 0,
      "needs_revalidation": false,
      "last_access": 1700000000000,
      "cache_inserted_at": 1699999000,
      "origin_max_age": 86400,
      "origin_s_maxage": 0,
      "origin_cc_flags": 0,
      "version": 5,
      "ssimulacra2_score": 82.5,
      "content_class": "photo"
    },
    {
      "alternate_id": 9,
      "size": 58400,
      "hit_count": 120,
      "is_sentinel": false,
      "mask": {
        "raw": 9,
        "format": "webp",
        "viewport": "desktop",
        "density": "1x",
        "save_data": false,
        "encoding": "identity"
      },
      "format": "webp",
      "viewport": "desktop",
      "content_type": "image",
      "origin_content_type": "image/jpeg",
      "ssimulacra2_score": 75.2,
      "content_class": "photo",
      "..."
    }
  ],
  "cooldown": {
    "reason": "revalidation",
    "remaining_seconds": 2,
    "duration_seconds": 3
  }
}

Sentinel entries (Early Hints, warmup, browser profile) are included with is_sentinel: true and a sentinel_name field.

The optional cooldown object appears when the URL is in a processing cooldown period. See Cooldowns for details.

GET /v1/cache/select

Simulate variant selection for a given capability mask. Shows how the selector scores each alternate and which one wins. Useful for debugging why a client receives a particular variant.

curl "http://localhost:9880/v1/cache/select?url=/images/hero.jpg&hostname=example.com&mask=9"

Query parameters:

ParameterTypeRequiredDescription
urlstringYesThe URL path
hostnamestringNoHostname for cache key
maskintYes32-bit capability mask (decimal)

The mask parameter is the decimal representation of the capability bitmask. Common values: 8 (Desktop/Original), 9 (Desktop/WebP), 10 (Desktop/AVIF).

Response:

{
  "url": "/images/hero.jpg",
  "hostname": "example.com",
  "client_mask": {
    "raw": 9,
    "format": "webp",
    "viewport": "desktop",
    "density": "1x",
    "save_data": false,
    "encoding": "identity"
  },
  "selected": {
    "alternate_id": 9,
    "score": 1280,
    "size": 58400,
    "content_type": "image"
  },
  "all_scores": [
    {
      "alternate_id": 8,
      "score": 100,
      "mask": { "format": "original", "..." },
      "content_type": "image"
    },
    {
      "alternate_id": 9,
      "score": 1280,
      "mask": { "format": "webp", "..." },
      "content_type": "image"
    }
  ]
}

GET /v1/cache/content

Retrieve the raw binary content of a specific alternate. Returns the actual optimized content (image bytes, minified CSS, etc.) as a binary blob.

curl "http://localhost:9880/v1/cache/content?url=/images/hero.jpg&hostname=example.com&alternate_id=9" \
  -o hero.webp

Query parameters:

ParameterTypeRequiredDescription
urlstringYesThe URL path
hostnamestringNoHostname for cache key
alternate_idintYesAlternate ID (low 8 bits of capability mask)

Response: Raw binary content with appropriate Content-Type header. The response includes Content-Disposition: attachment and security headers. SVG content includes a restrictive Content-Security-Policy.

Response headers:

Content-Type: image/webp
Content-Disposition: attachment
X-Content-Type-Options: nosniff
X-Alternate-Id: 9
X-Capability-Mask: 9

POST /v1/cache/purge

Delete cached variants for a URL, or purge the entire cache. Like every POST or PATCH under /v1/, a purge sent over the unix socket, or to a TCP API without a token, must carry X-Requested-With: XMLHttpRequest; without it the optimizer answers 400. The examples on this page send it, which does no harm when a token is configured.

Single URL purge:

curl -X POST http://localhost:9880/v1/cache/purge \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"url": "/images/hero.jpg", "hostname": "example.com"}'

Response:

{
  "url": "/images/hero.jpg",
  "hostname": "example.com",
  "deleted": 12
}

When deleted is 0, the response includes a note explaining the URL was not found in cache.

Full cache purge:

Purge all cached content. This stops the cache, deletes the volume file, and recreates it from scratch. Requires explicit confirmation.

curl -X POST http://localhost:9880/v1/cache/purge \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"scope": "all", "confirm": "purge-all"}'

Response:

{
  "scope": "all",
  "urls_cleared": 342,
  "volume_reset": true
}

Omitting the confirm field or providing the wrong value returns a 400 error.

POST /v1/cache/reprocess

Clear the dedup set and cooldown for a URL, then enqueue it for reprocessing. Unlike purge, this does not delete the cached content. The worker re-reads the original from cache and regenerates all optimized variants.

curl -X POST http://localhost:9880/v1/cache/reprocess \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"url": "/style.css", "hostname": "example.com"}'

Response:

{
  "url": "/style.css",
  "hostname": "example.com",
  "reprocess_enqueued": true
}

GET /v1/cache/cooldowns

List all URLs currently in a processing cooldown. Cooldowns prevent tight re-processing loops for the same URL.

curl http://localhost:9880/v1/cache/cooldowns

Query parameters:

ParameterTypeRequiredDescription
hostnamestringNoFilter by hostname

Response:

{
  "cooldowns": [
    {
      "url": "/index.html",
      "hostname": "example.com",
      "reason": "revalidation",
      "remaining_seconds": 2,
      "duration_seconds": 3
    },
    {
      "url": "/about.html",
      "hostname": "example.com",
      "reason": "tentative",
      "remaining_seconds": 45,
      "duration_seconds": 60
    }
  ],
  "count": 2,
  "enabled": true
}

Cooldown reasons:

ReasonDurationDescription
tentative60sProcessing in progress; prevents concurrent threads from duplicating work
write_failure60sCache write failed (reader contention on mmap handles)
revalidation3sHTML written with kFlagNeedsRevalidation; throttles CSS convergence loop

Browser capture

These endpoints require --enable-browser-analysis and a running Chrome instance. If Chrome is unavailable, the API returns 503.

POST /v1/capture/waterfall

Capture a network waterfall (HAR-like timing data) for a URL using headless Chrome.

curl -X POST http://localhost:9880/v1/capture/waterfall \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"url": "https://example.com", "viewport_width": 1280}'

Request body:

FieldTypeDefaultDescription
urlstring(required)Target URL (http or https)
viewport_widthint1280Browser viewport width (320-3840)

Response:

{
  "entries": [
    {
      "url": "https://example.com/",
      "resource_type": "document",
      "status": 200,
      "transfer_size": 12400,
      "decoded_size": 45000,
      "timing": { "..." }
    }
  ],
  "navigation_timing": { "..." },
  "total_transfer_size": 580000,
  "total_decoded_size": 1200000
}

POST /v1/capture/screenshot

Capture a PNG screenshot of a URL using headless Chrome.

curl -X POST http://localhost:9880/v1/capture/screenshot \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -d '{"url": "https://example.com", "viewport_width": 1440, "full_page": true}'

Request body:

FieldTypeDefaultDescription
urlstring(required)Target URL (http or https)
viewport_widthint1280Browser viewport width (320-3840)
viewport_heightint800Browser viewport height (200-10000)
full_pageboolfalseCapture entire page (not just viewport)

Response:

{
  "png_base64": "iVBORw0KGgoAAAANSUhEUgAA..."
}

The png_base64 field contains the screenshot as a base64-encoded PNG image.

Browser capture errors

StatusCondition
400Invalid URL, missing url field, viewport out of range
400URL targets private/loopback address (unless allowed)
503--enable-browser-analysis not set
503Chrome not running (crashed or still starting)
504Navigation timed out

Logs

GET /v1/logs

Recent log entries from the optimizer’s in-memory log (the same 2,000-entry ring the /v1/ws/logs stream starts from), for clients that poll over plain HTTP.

Over TCP this endpoint requires the API token even in read-open mode (see Authentication Modes); over the unix socket it is open to peers in group pagespeed like every endpoint.

curl "http://localhost:9880/v1/logs?limit=100"
curl "http://localhost:9880/v1/logs?since=1841"

Query parameters:

ParameterTypeDefaultDescription
sinceint-Return entries with a seq above this, oldest first. Without it, the newest entries.
limitint500Max entries to return (capped at 500; 0 means the default)

A page is also at most 512 KiB of JSON: a page of long lines holds fewer entries, and more says when to read again.

Response:

{
  "entries": [
    {
      "level": "info",
      "message": "cache flush complete",
      "module": "worker",
      "seq": 1842,
      "source": "worker",
      "timestamp": 1759230000000,
      "type": "log"
    }
  ],
  "gap": false,
  "more": false,
  "newest_seq": 1842,
  "next_since": 1842,
  "oldest_seq": 1,
  "shed_total": 0,
  "stream_id": "9f2c4e1a7b3d5c80"
}
FieldDescription
entriesLog entries, ascending by seq. The entry fields are the ones /v1/ws/logs sends.
next_sincePass this as since on the next read.
moreNewer entries are waiting beyond this page: read again now.
gapEntries between your since and oldest_seq were dropped from the ring before you read them.
oldest_seq / newest_seqThe oldest and newest entry the ring still holds.
shed_totalEntries not recorded because the optimizer was logging faster than it could keep; increases over the process’s life.
stream_idIdentifies this optimizer process. When it changes, the optimizer restarted and seq started again at 1: drop your since and read the newest entries.

A since or limit that is not a whole number is a 400.


WebSocket endpoints

WebSocket endpoints provide real-time streaming data. All three endpoints follow the same connection lifecycle:

  1. Client connects via HTTP upgrade to the WebSocket path
  2. If auth is configured, client sends {"auth": "my-token"} as first message
  3. Server sends initial snapshot
  4. Server pushes updates at regular intervals

Connection limits

ParameterValue
Max WebSocket connections (all streams)8
Max connections awaiting authentication16
… of which from any one remote address4
Ping interval30s
Pong timeout10s
Auth timeout2s

The “max connections awaiting authentication” limit is counted separately from the overall connection cap above (which counts every stream together, not per endpoint): at most 16 WebSocket connections may be simultaneously holding a slot while waiting for the in-band {"auth": "..."} message (see Authentication Modes), and at most 4 of those may share one remote address — enough for a client to open several streams at once (the bundled web console opens two per browser tab, statistics and log) while it authenticates, without a single never-authenticating address being able to take every slot for everyone else. Connections waiting to authenticate also count toward the overall cap of 8, which is reached first: in practice at most 8 can be waiting at once, and the limit of 16 applies only where the overall cap is higher than 16. From one address the limit of 4 is the one that applies. Clients that reach the optimizer through the same proxy or address share those 4 slots; an honest client holds one only until its token arrives. Connections on the unix socket need no token, so they never wait to authenticate and never count against these limits. A connection that is already authenticated — including one pre-authenticated under --api-read-open on an allow-listed stream — never counts against this budget, so it only bounds the number of handshakes that have completed but not yet proven a token. An upgrade that would exceed it is refused the same way exceeding the overall connection cap is. A well-behaved client does not keep retrying a stream the server has rejected for a missing or wrong token: the shipped web console opens every stream immediately (correct for the common case, since most deployments serve at least some streams with no token at all) and, if a stream it opened this way is closed for an authentication reason, waits for a new token rather than reconnecting into the same rejection.

GET /v1/ws/stats

Live server statistics with delta compression. Sends a full snapshot on connect, then periodic deltas containing only changed fields.

const ws = new WebSocket('ws://localhost:9880/v1/ws/stats');

// If auth is configured:
ws.onopen = () => ws.send(JSON.stringify({ auth: 'my-token' }));

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'snapshot') {
    // Full stats object (same schema as GET /v1/stats)
    console.log('Full stats:', msg.data);
  } else if (msg.type === 'delta') {
    // Only fields that changed since last message
    console.log('Changed:', msg.data);
  }
};

Server messages:

TypeDescription
snapshotFull stats on connect: {type: "snapshot", sequence: N, data: StatsResponse}
deltaChanged fields only: {type: "delta", sequence: N, data: Partial<StatsResponse>}

Client messages:

MessageDescription
{"auth": "token"}Authenticate (required when auth is configured)
{"set_interval_ms": N}Change push interval (100ms - 60000ms, default 1000ms)

Sequence numbers increment per message for loss detection. Delta compression reduces typical message size from ~50 KB to ~1 KB.

GET /v1/ws/events

Real-time optimization events. Events are batched at 100ms intervals.

const ws = new WebSocket('ws://localhost:9880/v1/ws/events');
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'event') {
    console.log(msg.event_type, msg.detail);
  } else if (msg.type === 'overflow') {
    console.log('Dropped', msg.dropped_count, 'events');
  }
};

Server messages:

TypeDescription
event{type: "event", sequence: N, timestamp: MS, event_type: "...", detail: {...}}
overflow{type: "overflow", dropped_count: N} — oldest events dropped when buffer exceeds 64 KB

Event types:

EventDescription
svg_candidacyImage evaluated for SVG vectorization
svg_vectorizedSVG vectorization completed
svg_writtenSVG variant written to cache

GET /v1/ws/logs

Real-time log streaming. Sends the ring buffer contents (up to 2000 entries) on connect, then streams new log entries as they occur.

const ws = new WebSocket('ws://localhost:9880/v1/ws/logs');
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'snapshot') {
    console.log('History:', msg.entries.length, 'entries');
  } else if (msg.type === 'log') {
    console.log(`[${msg.level}] ${msg.module}: ${msg.message}`);
  }
};

Server messages:

TypeDescription
snapshotRing buffer on connect: {type: "snapshot", entries: LogEntry[], total: N}
logSingle log entry: {type: "log", seq: N, timestamp: MS, source: "...", level: "...", module: "...", message: "...", details?: {...}}
overflowEntries this client was not sent: {type: "overflow", dropped_count: N, first_seq: A, last_seq: B}

Every entry carries seq, a sequence number that increases by one per entry for as long as the optimizer process runs (the first entry is seq 1, and numbering starts again at 1 after a restart, so since=0 always reads from the beginning). message is always valid UTF-8 — bytes that are not are shown as U+FFFD — and its content is capped at 4 KiB: a longer message keeps its first 4 KiB (cut on a character boundary) followed by a …[truncated N bytes] suffix, so a truncated message is slightly longer than 4 KiB overall.

New entries are sent in batches (about every 100 ms), one log message per entry, in seq order, and as fast as the client reads them. Every entry the optimizer still holds reaches the client: the stream reads from the same ring of the most recent 2000 entries that the snapshot and GET /v1/logs read from. A client only misses entries that left that ring before it could be sent them — it reads too slowly, or more than 2000 entries arrived at once. It then receives one overflow message in their place: dropped_count entries, seq first_seq through last_seq, were not sent, and the next log message is last_seq + 1. Those entries are gone from GET /v1/logs as well, which reports them as a gap. A client that does not need the exact range can read dropped_count alone.

Under --api-read-open with a token configured, this stream still requires it: the handshake succeeds, but the connection is closed unless the token arrives as the first message within the auth timeout — the same step every client already uses to authenticate (see Authentication Modes). Over the unix socket, and on a tokenless API opened with --api-no-auth, the stream is open.

Log sources: worker, cache, chrome

Log levels: debug, info, warning, error


Static files

GET /console/*

Serves the web console SPA (SvelteKit build). Content-Type is auto-detected from the file extension. Configure the console directory with --console-dir.

curl http://localhost:9880/console/

This endpoint is not under the /v1/ prefix. It serves static files and does not return JSON.


Error response format

All API errors follow a consistent JSON structure:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Missing 'url' query parameter"
  }
}

Some errors include a details field with additional context:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Validation failed",
    "details": { "..." }
  }
}

Error codes

HTTP StatusCodeDescription
400BAD_REQUESTInvalid request (missing parameters, malformed JSON)
401UNAUTHORIZEDMissing or invalid auth scheme
403FORBIDDENInvalid token
404NOT_FOUNDEndpoint or resource not found
405METHOD_NOT_ALLOWEDWrong HTTP method for endpoint
413PAYLOAD_TOO_LARGERequest body exceeds 64 KB
429TOO_MANY_REQUESTSConnection limit reached
500INTERNAL_ERRORServer-side failure
503SERVICE_UNAVAILABLEFeature not enabled or dependency unavailable
504GATEWAY_TIMEOUTRequest timed out

Endpoint summary

MethodPathAuthDescription
GET/v1/healthNoHealth check and readiness
GET/v1/statsYesFull statistics (JSON)
GET/v1/metricsYesStatistics (Prometheus format)
GET/v1/configYesCurrent configuration
PATCH/v1/configYesUpdate configuration
GET/v1/cache/urlsYesPaginated cached URL list
GET/v1/cache/alternatesYesList variants for a URL
GET/v1/cache/selectYesSimulate variant selection
GET/v1/cache/contentYesRaw alternate content
POST/v1/cache/purgeYesPurge URL or full cache
POST/v1/cache/reprocessYesRe-optimize a URL
GET/v1/cache/cooldownsYesList active cooldowns
POST/v1/capture/waterfallYesNetwork waterfall capture
POST/v1/capture/screenshotYesPage screenshot capture
WS/v1/ws/statsYesLive stats streaming
WS/v1/ws/eventsYesReal-time optimization events
WS/v1/ws/logsYesReal-time log streaming
GET/console/*NoWeb console static files

Next steps

Search