ข้ามไปยังเนื้อหา

Caching & Edge

หน้า static คือ case ในอุดมคติอยู่แล้ว: build ครั้งเดียว serve จาก CDN cache ได้ทุกที่ คำถามที่น่าสนใจคือจะทำยังไงเมื่อ route ถูก render แบบ on demand — เราไม่อยากให้ render ซ้ำทุกครั้งที่มีคนเข้าถ้า result เหมือนเดิมอยู่พักหนึ่ง นี่แหละคือหน้าที่ของ caching: เปลี่ยน render ซ้ำ ๆ ให้เป็น lookup ที่ถูกกว่า

สำหรับ route ที่ render แบบ on demand (prerender = false) เราตั้ง caching header บน response แล้ว browser กับ CDN ที่อยู่หน้า server จะเอา response เดิมมาใช้ซ้ำแทนที่จะยิง render ใหม่:

---
export const prerender = false;
// Cache this response at the CDN for 1 hour; allow serving a stale copy
// for a day while revalidating in the background.
Astro.response.headers.set(
'Cache-Control',
'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400'
);
const articles = await getArticles();
---
<ArticleList articles={articles} />

พิสูจน์ครบวงจร — server Node จริงที่ build จาก header call ตัวนี้เป๊ะ ๆ ยิงด้วย curl -D -:

$ curl -s -D - http://127.0.0.1:4701/experiments/cache-header/ -o /dev/null
HTTP/1.1 200 OK
cache-control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
content-type: text/html

directive สำคัญ: s-maxage คุมว่า cache แบบ shared (CDN) จะเก็บนานแค่ไหน และ stale-while-revalidate ให้ CDN serve ของเก่าที่ยัง stale อยู่นิดหน่อยได้ทันทีในขณะที่ refresh ใน background — ผู้ใช้แทบไม่ต้องรอ cold render เลย

call เดียวกันบนหน้าที่มี export const prerender = true build ผ่านโดยไม่มี error หรือ warning เลย — แต่มันคือ no-op เปล่า ๆ พิสูจน์แล้ว: build หน้านั้นได้ไฟล์ .html เรียบ ๆ ไม่มี header artifact ใด ๆ แนบมาใน dist/ เลย (ไม่มีไฟล์ _headers ไม่มีอะไรทั้งนั้น) — static host serve ไฟล์นั้นด้วย default header ของตัวเอง เพราะ static build ไม่มีกลไกพก response header ติดไปด้วยเลย:

---
export const prerender = true;
Astro.response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
---
<p>prerendered — headers set here have nowhere to go</p>
<!-- the entire built file — no header sidecar exists anywhere in dist/ -->
<!DOCTYPE html><script type="module" src="/_astro/page.js"></script><p>prerendered — headers set here have nowhere to go</p>

ถ้าต้องการ caching header บน route ที่ prerender จริง ๆ นั่นเป็นเรื่องของ hosting/CDN (ไฟล์ _headers สำหรับ Netlify, rule ใน vercel.json ฯลฯ) — Astro เองส่ง Astro.response.headers ผ่านให้เฉพาะ route แบบ on-demand เท่านั้น ที่มี Response ต่อ request จริง ๆ ให้ header เกาะได้

CDN cache response ไว้ที่ location edge ใกล้ผู้ใช้ ทำให้หน้าที่ cache ไว้ถูก serve จากเมืองใกล้ ๆ แทนที่จะเป็น origin ของเรา เว็บ static ของ Astro ถูก edge-cache โดยธรรมชาติอยู่แล้ว ส่วนเว็บ on-demand จะ edge-friendly ทันทีที่เราใส่ Cache-Control ที่ถูกต้อง — CDN cache HTML ที่ render แล้วไว้ที่ edge และเรียก server ของเราเฉพาะตอน cache หมดอายุ

flowchart LR
  user["User request"] --> edge["CDN edge"]
  edge -->|cache hit| fast["Serve cached HTML (fast)"]
  edge -->|cache miss| origin["Origin renders, then caches at edge"]
  origin --> edge
edge serve HTML ที่ cache ไว้; origin render เฉพาะตอน miss

Astro 7 เลื่อน route caching จาก experimental flag ขึ้นมาเป็น config ระดับบนสุดที่ stable แล้ว อ่านตรงจาก compiler ที่ติดตั้งจริง (astro/dist/core/cache/config.js) schema จริงคือ:

CacheProviderConfigSchema = { entrypoint: string | URL, name?: string, config?: Record<string, any> }
CacheSchema = { provider?: CacheProviderConfigSchema }
CacheOptionsSchema = { maxAge?: number, swr?: number, tags?: string[] }
RouteRulesSchema = Record<routePattern, CacheOptionsSchema>
astro.config.mjs
import { defineConfig, memoryCache } from 'astro/config';
export default defineConfig({
output: 'server',
cache: { provider: memoryCache() }, // built-in in-memory provider: { name: 'memory', entrypoint: 'astro/cache/memory' }
routeRules: {
'/blog/[slug]': { maxAge: 3600, swr: 86400, tags: ['blog'] },
},
});

memoryCache() (export จาก astro/config ตรวจสอบตรงจาก astro/dist/config/entrypoint.js) เป็น provider แบบ in-memory instance เดียว — เหมาะกับ setup แบบ dev หรือ server เดียว ไม่เหมาะกับ deployment หลาย instance (แต่ละ instance มี cache ของตัวเอง) แพลตฟอร์ม CDN จะมี provider package ของตัวเองสำหรับ edge cache จริง

นี่ไม่ใช่ header ตัวเดียวกับที่เราตั้งเอง — พิสูจน์แล้ว

หัวข้อที่มีชื่อว่า “นี่ไม่ใช่ header ตัวเดียวกับที่เราตั้งเอง — พิสูจน์แล้ว”

ส่วนนี้ควรอ่านให้ละเอียด: routeRules/cache ไม่ได้ตั้ง header Cache-Control ที่ browser เห็นเลย อ่านจาก astro/dist/core/cache/runtime/utils.js — maxAge/swr/tags ของ route ที่ match จะถูกแปลงเป็น header ภายในที่ต่างออกไป:

// what routeRules actually produces, verified in astro/dist/core/cache/runtime/utils.js
headers.set('CDN-Cache-Control', `max-age=${maxAge}, stale-while-revalidate=${swr}`);
headers.set('Cache-Tag', tags.join(', '));

…และ astro/dist/core/cache/handler.js ลบ header ทั้งสองตัวนี้ก่อน response จะไปถึง client (response.headers.delete('CDN-Cache-Control') / .delete('Cache-Tag')) มันมีไว้แค่บอก cache.provider ที่ตั้งไว้ว่าจะเก็บ response นี้นานแค่ไหน — provider ดัก request ไว้ ตัดสินใจ hit-หรือ-miss เอง แล้ว CDN/browser ไม่เห็น CDN-Cache-Control เลย

พิสูจน์ด้วย request จริงบน route ที่มี routeRules: { '/experiments/route-cache': { maxAge: 20 } } กับ cache.provider: memoryCache() ที่ render UUID สุ่มทุกครั้ง:

$ curl -s -D - http://127.0.0.1:4701/experiments/route-cache/
HTTP/1.1 200 OK
content-type: text/html
x-astro-cache: MISS
<p>5e3bae66-70b5-4adf-9236-ffa79ff2f9ec</p>
$ curl -s -D - http://127.0.0.1:4701/experiments/route-cache/
HTTP/1.1 200 OK
content-type: text/html
x-astro-cache: HIT
<p>5e3bae66-70b5-4adf-9236-ffa79ff2f9ec</p>

UUID เหมือนเดิมทั้งสองครั้ง (x-astro-cache เปลี่ยนจาก MISS → HIT) — request ที่สองไม่ได้ re-run หน้าเลยด้วยซ้ำ — และสังเกตว่าไม่มี header Cache-Control หรือ CDN-Cache-Control ที่ client เห็นเลยในทั้งสอง response ถ้าอยากให้ browser/downstream CDN cache ด้วย ก็ยังต้องตั้ง Cache-Control เองด้วย Astro.response.headers.set(...) เหมือน section ก่อนหน้า — routeRules กับการตั้ง Cache-Control เองเป็นสอง layer ที่แยกกันโดยสิ้นเชิง แค่บังเอิญใช้ศัพท์ maxAge/swr ร่วมกัน

อีกเรื่องที่ตรวจสอบตรงจาก astro/dist/core/cache/handler.js: provideCache() จะสลับไปใช้ NoopAstroCache ทุกครั้งที่ runtimeMode === 'development' — routeRules/cache ไม่ทำอะไรเลยใน astro dev ทำงานเฉพาะตอน build/production run เท่านั้น ต้อง test ด้วย astro build + astro preview (หรือ server ของ adapter ที่ใช้) ไม่ใช่ astro dev

Server island (server:defer) เป็น on-demand เสมอ (prerender: false แบบไม่มีเงื่อนไข — ตรวจสอบตรงจาก astro/dist/core/server-islands/endpoint.js) อ่านไฟล์เดียวกันนี้ header ตัวเดียวที่ Astro เองตั้งบน response ของ island คือ X-Robots-Tag: noindex — ไม่มี Cache-Control default เลย การ cache (หรือตั้งใจไม่ cache) island เป็นเรื่องของ component เองทั้งหมด:

---
// src/components/Greeting.astro — a server island
Astro.response.headers.set('Cache-Control', 'private, no-store');
const name = 'Ana';
---
<p>Welcome back, {name}</p>

พิสูจน์แล้ว — request จริงที่หน้าหนึ่งยิงไปดึง island นี้ กับ response จริง:

$ curl -s -D - "http://127.0.0.1:4701/_server-islands/Greeting?e=...&p=&s="
HTTP/1.1 200 OK
cache-control: private, no-store
content-type: text/html
x-robots-tag: noindex
<p>Welcome back, Ana</p>

private, no-store มาจาก Astro.response.headers.set(...) ของ component เอง — Astro ไม่ได้เติมให้ x-robots-tag: noindex เป็นของ Astro แบบไม่มีเงื่อนไข บน response ของ server island ทุกตัว (search engine เลย index URL ของ fragment นี้ไม่ได้)

SSR แบบ per-request ล้วน ๆ cache ยาก — ถ้าหน้ามีคำว่า “Welcome back, Ana” เราแชร์ response นั้นให้ visitor คนถัดไปไม่ได้ ทางแก้ที่สวยคือรวมสิ่งที่ module ก่อนหน้าสอนไว้: ทำให้ page shell เป็น static/cacheable แล้วส่งเฉพาะส่วนที่ personalizeไปที่ server island (server:defer)

---
// This page is static and fully cacheable at the edge...
---
<Layout>
<ArticleList /> <!-- same for everyone: cached -->
<UserGreeting server:defer> <!-- per-user: deferred, fetched separately -->
<p slot="fallback">Loading…</p>
</UserGreeting>
</Layout>

ตอนนี้ส่วนหนักที่ใช้ร่วมกันถูก cache ไว้ที่ edge serve ได้ทันที ส่วน island เล็ก ๆ ที่ personalize ก็ render ต่อ request — และอย่างที่พิสูจน์ไปแล้ว request ของ island นั้นมี cache decision ของตัวเอง (ปกติคือ no-store) แยกขาดจาก caching ของ shell โดยสิ้นเชิง

ถ้าหน้าส่วนใหญ่เหมือนกันสำหรับทุกคนมี dynamic bit นิดหน่อย static (หรือ cached) + islands ชนะ full SSR แทบทุกครั้ง: cache ได้ดีกว่า, load server น้อยกว่า, response เร็วกว่า เก็บ full on-demand rendering ไว้กับหน้าที่ต่างกันจริง ๆ ทุก request (dashboard ส่วนตัว, ผลค้นหา) การเลือกตัวเลือกที่ cache ได้ เองก็คือการตัดสินใจเรื่อง performance อยู่แล้ว

guide ทั่วไปของ Cloudflare adapter (docs.astro.build) พูดถึง cache header อายุยาวอัตโนมัติสำหรับ static asset ที่มี hash และการเข้าถึง caches API ของแพลตฟอร์มตรง ๆ (caches.default.put(request, response)) สำหรับทำ edge caching เอง — ไม่ได้พูดถึง integration กับ cache.provider เอง ส่วน configuration reference ของ Astro เองแยกต่างหาก list Netlify, Vercel และ Cloudflare ว่าเป็นแพลตฟอร์มที่มี cache.provider package integrated มาให้สำหรับ layer cache/routeRules ที่อธิบายไว้ข้างบน เราไม่สามารถรัน Cloudflare Worker จริงใน environment นี้เพื่อพิสูจน์ provider นั้นแบบครบวงจรได้ — ให้ cite docs สำหรับ provider เฉพาะแพลตฟอร์ม และตรวจสอบ version ที่ใช้ก่อนพึ่งพามัน

  • snippet สมมติว่า routeRules: { '/x': { maxAge: 60 } } เพียงอย่างเดียวทำให้ browser cache response หรือเปล่า? พิสูจน์ข้างบนแล้วว่า: มันขับเคลื่อน header CDN-Cache-Control ภายในที่ถูกลบก่อน client จะเห็น — ต้องเพิ่ม Astro.response.headers.set('Cache-Control', ...) เองด้วยถ้านั่นคือเป้าหมาย
  • call ตั้ง cache header (Astro.response.headers.set) ถูกวางบนหน้าที่มี export const prerender = true ด้วยหรือเปล่า? พิสูจน์ข้างบนแล้วว่า: มันไม่ทำอะไรเลยเงียบ ๆ — ย้าย logic header ไปที่ hosting config หรือถอด prerender = true ออกถ้า response นี้ต้อง vary จริง ๆ
  • server island สมมติว่ามันสืบทอด Cache-Control จาก shell page หรือเปล่า? พิสูจน์ข้างบนแล้วว่า: Astro ตั้งแค่ X-Robots-Tag: noindex ให้ response ของ island โดย default — อะไรที่ cache ได้ (น้อย) หรือตั้งใจ no-store (บ่อยกว่า) ต้องตั้งเองข้างใน island
  • claim เรื่อง caching ถูก test กับ astro dev หรือเปล่า? routeRules/cache เป็น no-op ใน dev — ต้อง test กับ server ที่ build แล้ว

🐛 หา bug ในโค้ดนี้

src/pages/dashboard.astro
---
export const prerender = true;
// "cache this for a day at the CDN"
Astro.response.headers.set('Cache-Control', 'public, max-age=86400');
const stats = await getDashboardStats();
---
<Dashboard stats={stats} />
เฉลย

หน้านี้เป็น prerender = true — หน้า static ที่ build ล่วงหน้าครั้งเดียว พิสูจน์ข้างบนแล้วว่า: static build ไม่มีกลไกพก response header ไปด้วยเลย ดังนั้น Astro.response.headers.set(...) ตัวนี้ build ผ่านโดยไม่มี error แต่ไม่มีผลอะไรเลย ไฟล์ที่ deploy ถูก serve ด้วย default header ของ host ไม่ใช่ตัวนี้ ถ้า stats ต้อง fresh ต่อ visitor จริง ๆ (ซึ่งเป็นเหตุผลที่ผู้เขียนอยากใช้ Cache-Control ตั้งแต่แรก) ทางแก้คือ export const prerender = false เพื่อให้มี response ต่อ request จริง ๆ ให้ header เกาะได้ — หรือถ้า snapshot อายุวันหนึ่งใช้ได้กับทุกคนจริง ๆ ก็ถอด header ทิ้งไปเลย แล้วปล่อยให้ static build cache ได้เองอยู่แล้ว

Astro 7 มี caching layer ที่เป็นอิสระจากกันสองชั้น และการสับสนสองอันนี้คือความผิดพลาดที่พบบ่อยที่สุด: Astro.response.headers คือ layer HTTP ธรรมดา — header จริงบน Response จริงต่อ request มีความหมายก็ต่อเมื่อมี request ให้มันเกาะ (prerender = false) cache/routeRules คือ caching layer ของ Astro เอง — มันคุยกับ provider ที่ตั้งไว้ผ่าน header (CDN-Cache-Control, Cache-Tag) ที่ไม่มีทางไปถึง browser เลย เป็นคนตัดสินใจว่า Astro จะ re-run หน้านี้หรือไม่ server island คือ sub-request on-demand เล็ก ๆ แยกต่างหาก มี cache decision เป็นของตัวเอง และถูก tag X-Robots-Tag: noindex โดย Astro เสมอไม่ว่ากรณีไหน

Astro.response.headers cache/routeRules Server island (server:defer)
Layer response header HTTP จริง cache ภายในของ Astro ผ่าน provider sub-request on-demand แยกต่างหาก
browser/CDN เห็นไหม เห็น ไม่เห็น (CDN-Cache-Control/Cache-Tag ถูกลบ) แล้วแต่ island ตั้งเอง
ใช้บนหน้า prerender ได้ไหม ไม่ได้ — no-op เงียบ ๆ N/A (routing เป็น on-demand เสมอ) N/A
ทำงานใน astro dev ไหม ทำงาน ไม่ — ปิดใน dev ทำงาน
เหมาะกับ คุมเองตรง ๆ หรือป้อน CDN จริง แชร์ policy cache ให้หลาย route ผ่าน config เดียว personalize fragment เดียวของหน้าที่ cache ได้ส่วนใหญ่
  • “ตั้ง routeRules: { maxAge: 60 } แล้ว browser จะ cache หน้านี้ 60 วินาที” พิสูจน์ข้างบนแล้วว่า: มันตั้ง header CDN-Cache-Control ภายในที่ถูกลบก่อน client จะเห็น — browser ไม่ได้รับคำสั่ง caching จาก routeRules เพียงอย่างเดียวเลย
  • “header Cache-Control ที่ตั้งไว้ที่ไหนก็ได้ใน frontmatter ของหน้าใช้ได้เสมอ” พิสูจน์ข้างบนแล้วว่า: มันทำงานเฉพาะเมื่อหน้าเป็น on-demand (prerender = false) เท่านั้น; บนหน้า prerender call นี้เป็น no-op เต็ม ๆ ไม่ใช่ no-op บางส่วน
  • “response ของ server island ถูก cache เหมือนกับ shell page” พิสูจน์ข้างบนแล้วว่า: Astro ตั้งแค่ X-Robots-Tag: noindex ให้มันโดย default — caching ใด ๆ (หรือ no-store ตรง ๆ) เป็นความรับผิดชอบของ island component เอง
  • “routeRules/cache ทำงานเหมือนกันทั้งใน astro dev และ production” ตรวจสอบตรงจาก source แล้วว่า: dev mode สลับไปใช้ cache แบบ no-op — ต้อง test กับ server ที่ build แล้วจริง ๆ
สำหรับ route แบบ on-demand จะให้ CDN cache response ได้ยังไง และพิสูจน์แล้วหรือยังว่ามันไปถึง client จริง?
header ตัวนี้เกิดอะไรขึ้นจริง ๆ?
---
export const prerender = true;
Astro.response.headers.set('Cache-Control', 'public, max-age=86400');
---
<p>hi</p>
routeRules: { '/x': { maxAge: 60 } } (พร้อม cache.provider) ตั้งอะไรจริง ๆ บน response ที่ออกไป ตรวจสอบจาก source แล้ว?
cache/routeRules ทำอะไรบ้างตอนรัน astro dev?
Astro เองตั้ง header อะไรเป็น default บน response ของ /_server-islands/<name>?
pattern "shell ที่ cache ได้ + server island" คืออะไร และ caching ของ island เองเป็นยังไง?