Skip to content

Gaps — gw2.app caching/perf strategy vs GW2Priory ​

Raw observations from analysing a competitor site (gw2.app) and comparing its data-fetching and caching strategy against GW2Priory's, recorded where they were noticed and deliberately left unprocessed. Nothing here is a proposal, a decision, or a plan — no spec owns a "make it faster" change, and inventing one while passing through would be scope creep. This is what was measured and what the code currently does, so a future spec can start from evidence instead of memory.

Measured 2026-08-22 by driving gw2.app in a browser (network capture + reading response bodies and headers) and reading apps/api / apps/web. Captured both logged out and logged in (the account-authenticated behaviour below is observed, not inferred).


What gw2.app actually does (measured) ​

Cache headers — everything is cached, at every layer ​

Resourcecache-controlEdge
HTML document (/)max-age=14400 (4h)Cloudflare, cf-cache-status: HIT, x-sveltekit-page: true (SSR)
Static assets (_app/immutable/*)public, max-age=31536000, immutableCloudflare HIT (observed age ~23 days)
Every /api/v1/* responsemax-age=14400 (4h)Cloudflare (observed MISS, but browser-cached 4h)
Official API prices (api.guildwars2.com/v2/commerce/prices)public, max-age=120 (2min)ArenaNet

The ?v=2025-08-29T01:00:00.000Z param is not a cache-buster — it is ArenaNet's official GW2 API v2 schema-version selector, pinned to one fixed value (identical across days and across every call). Caching of the direct ArenaNet calls is governed by their cache-control alone (below).

Direct ArenaNet call cache posture (measured 2026-08-22) ​

Endpointcache-controlShared?
/v2/items/{id} (static)public, max-age=3600 (1h)yes
/v2/commerce/pricespublic, max-age=120 (2min)yes
/v2/account*, /v2/characters, /v2/pvp/* (authenticated)private (401 without key)no — per-user, browser only

Response shape — fat, denormalized, one call per view ​

  • GET /api/v1/items/30687 returns a composite, not a raw GW2 item: { item, acquisitionMethods, craftable, usedIn, keywords }. item itself is the raw GW2 item shape plus a computed isTpItem flag.
  • GET /api/v1/search?terms=…&type=item&page=1 returns { mightHaveMore, results } with 20 fully-hydrated items per page — each result embeds the full item object and its expanded suffix_item (e.g. Frostfang comes back with its Superior Sigil of Ice fully inlined). No second call to hydrate a search row.
  • GET /api/v1/crafting/tree/item/30684?prices=buys&researchNotes=true returns the entire recursive bill-of-materials in one response: 357 nodes, 12 levels deep, every node embedding its full item object. 400 KB raw → 26 KB brotli / 56 KB gzip on the wire.

Static / account / live split — prices are NOT in the backend responses ​

The crafting tree contains no trading-post prices — only static vendor_value. This is the crux:

  1. Static reference — materials/item-ids (array of item ids), item-stats (191 stat combos), currencies, item metadata, recipe trees. Fetched once, browser-cached 4h. Because these carry no volatile data, they can be cached for hours.
  2. Account state (observed logged in) — /v2/account, /v2/account/{masteries,mastery/points, achievements,mapchests,luck}, /v2/pvp/stats, /v2/characters?ids=all. Fetched client-side, directly from api.guildwars2.com, with the player's API key as ?access_token=… in the URL. The key and account data never touch gw2.app's backend. Responses are private (per-user, browser cache only) and reused across navigation — a full reload of /lists did not refetch the characters/achievements/masteries already pulled on /summary; only mapchests (daily-reset data) refetched. Owned-material/wallet reconciliation was not observed because the test account had no lists; it would fetch the same way on demand.
  3. Live market — commerce/prices (bulk), commerce/listings/{id} (order book), wizardsvault. Fetched client-side, directly from api.guildwars2.com, 2-min cache. Never touches gw2.app's own backend or its rate budget.

The consequence that makes the whole scheme work: gw2.app's own backend serves zero per-user data. Every /api/v1/* response is user-agnostic, which is why it can carry a shared max-age=14400 that benefits all users at once. Everything per-user (account) or volatile (prices) is offloaded to direct browser↔ArenaNet calls, cached privately/short in the user's own browser.

Item-detail history (items/{id}/prices?from=…&to=…, unix-timestamp range) is served by the backend as its own series — that is snapshot history, not the live quote.

Client behaviour ​

SvelteKit SPA, per-route code-split chunks. Global reference data + the "My Lists"/dailies layout panels load once in the root layout and are not refetched on client-side navigation (verified: navigating /items → /maps did not re-fire materials/item-ids, achievements/points, etc.). A POST /api/session fires on every navigation (session/analytics ping).


How GW2Priory differs (from the code) ​

Agreement on one axis, divergence on the rest. Evidence is file:line in apps/api / apps/web.

Axisgw2.appGW2PrioryEvidence
Crafting-tree shapeone recursive tree, metadata embedded, no client waterfallsame — one recursive PricedTreeNode, batched enrich + price overlappedrecipe-graph.schema.ts:70-86, recipe-graph.service.ts:66-86
CDN / edge on APICloudflare, API edge-cachednone; API direct from a single Frankfurt origin, CDN only fronts the static shellrender.yaml:4-12, deploy.md:43-47
HTTP cache headersmax-age=14400 on all API + HTMLnone anywhere (0 hits for cache-control/etag/setHeader in apps/api)grep apps/api/src; main.ts:13-32
Cold startalways-warm edgeRender free tier, ~1 min cold start after idledeploy.md:86-89
Repeat views / nav4h browser cache → no refetchTanStack QueryClient with no config → staleTime: 0 → background refetch on every mount; cache dies on reloadApiProvider.tsx:4; react.md:302-305
Pricesclient-side, direct to ArenaNet, 2-min, separate from treefetched server-side (60s TTL) and folded into the tree responsegw2-client.ts:50, recipe-graph.service.ts:73-85
RenderingSSR + edge-cached HTML + per-route chunkspure SPA, no route code-splitting, blank #root until JS + first query resolvemain.tsx:6-30,37-43; react.md:272-274
Account data + API keybrowser → ArenaNet directly (?access_token); backend never sees the key or account data; backend stays 100% user-agnostic and shared-cacheableproxied through the origin — key sent to apps/api as Authorization: Bearer, forwarded to GW2; account reads mixed into responses (e.g. ranking overlaps a ~40-request account scan with graph resolution)stack.md:20-25, react.md:135-136; ranking.service.ts:109-128, gw2-client.ts account cache

Server-side caching does exist, but it caches the primitives, not the answer: BoundedCache instances in gw2-client.ts:109-129 (static = no expiry, prices = 60s, account = 5min), in-process, FIFO not LRU (bounded-cache.ts:46-51). The assembled recipe tree is recomputed per request — no memoised response artefact, and nothing tells the browser or an edge to reuse it.

The GW2 client is otherwise sound: 199-id batching (gw2-client.ts:43,397-451), token bucket + backoff (token-bucket.ts, gw2-client.ts:57-63), and a committed static recipe index so known recipes need no live round-trip (static-data/data/station-recipes.ts, recipe-index.service.ts:48-55).


The performance-relevant gap (observation, not a plan) ​

The slowness is dominated by caching + infrastructure, not algorithm. GW2Priory computes a correct, batched, waterfall-free tree — then discards it: recomputed per request, no HTTP cache, no edge, on a cold-startable free origin, with 60s prices welded into the payload so the expensive static structure inherits a 60s life and can never be cached for hours.

The enabling difference is what the backend refuses to handle. gw2.app's backend responses are cacheable for hours across all users because they carry no volatile and no per-user data — prices and account state are both offloaded to direct browser↔ArenaNet calls (prices public/short, account private/per-user, the key never leaving the browser). GW2Priory folds volatile prices into /recipe-graph/:itemId (kills long caching) and proxies per-user account data through the same cold-startable origin (kills shared caching and spends the origin's latency + rate budget on work gw2.app pushes entirely to the client). Both are independent of any CDN or cold-start question.

Ranked by likely impact on perceived latency:

  1. ~1-minute free-tier cold start (deploy.md:86-89).
  2. No CDN + no cache headers on API data — every repeat of an identical tree is a full Frankfurt round-trip.
  3. Prices coupled into the tree server-side — kills long caching of the structure.
  4. SPA with no code-splitting + staleTime: 0 — blank page until a big bundle boots and suspends on a cold origin, then refetches on every navigation.
  5. Account data proxied through the origin instead of browser↔ArenaNet — makes those responses per-user (unshareable), adds an origin round-trip, and spends the shared GW2 rate budget on per-user work.

Side finding — Postgres claimed but absent ​

stack.md:8 ("Postgres for persistence") and nestjs.md:115 ("persistence is Postgres for user data only") describe a store that does not exist in the code, app.module.ts, or render.yaml (0 hits for postgres/pg/typeorm/prisma/drizzle/kysely/DATABASE_URL). Today the system is stateless compute + committed static files + in-memory cache. Docs and code diverge; worth reconciling in whichever spec next touches persistence.