Caching & Edge
response ที่เร็วที่สุดคือ response ที่ไม่ต้อง compute
หัวข้อที่มีชื่อว่า “response ที่เร็วที่สุดคือ response ที่ไม่ต้อง compute”หน้า static คือ case ในอุดมคติอยู่แล้ว: build ครั้งเดียว serve จาก CDN cache ได้ทุกที่ คำถามที่น่าสนใจคือจะทำยังไงเมื่อ route ถูก render แบบ on demand — เราไม่อยากให้ render ซ้ำทุกครั้งที่มีคนเข้าถ้า result เหมือนเดิมอยู่พักหนึ่ง นี่แหละคือหน้าที่ของ caching: เปลี่ยน render ซ้ำ ๆ ให้เป็น lookup ที่ถูกกว่า
Cache-Control บน on-demand response
หัวข้อที่มีชื่อว่า “Cache-Control บน on-demand response”สำหรับ 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/nullHTTP/1.1 200 OKcache-control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400content-type: text/htmldirective สำคัญ: s-maxage คุมว่า cache แบบ shared (CDN) จะเก็บนานแค่ไหน และ stale-while-revalidate ให้ CDN serve ของเก่าที่ยัง stale อยู่นิดหน่อยได้ทันทีในขณะที่ refresh ใน background — ผู้ใช้แทบไม่ต้องรอ cold render เลย
หน้า prerender: Astro.response.headers ไม่มีที่ไป
หัวข้อที่มีชื่อว่า “หน้า prerender: Astro.response.headers ไม่มีที่ไป”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 เกาะได้
Edge caching
หัวข้อที่มีชื่อว่า “Edge caching”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
Route caching ใน config (Astro 7) — schema จริง
หัวข้อที่มีชื่อว่า “Route caching ใน config (Astro 7) — schema จริง”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>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.jsheaders.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 OKcontent-type: text/htmlx-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 OKcontent-type: text/htmlx-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: ไม่มี default cache header มีแค่ X-Robots-Tag
หัวข้อที่มีชื่อว่า “Server island: ไม่มี default cache header มีแค่ X-Robots-Tag”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 islandAstro.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 OKcache-control: private, no-storecontent-type: text/htmlx-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 นี้ไม่ได้)
pattern ที่ทรงพลัง: shell ที่ cache ได้ + server island
หัวข้อที่มีชื่อว่า “pattern ที่ทรงพลัง: shell ที่ cache ได้ + server island”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 โดยสิ้นเชิง
เมื่อ static-with-islands ดีกว่า full SSR
หัวข้อที่มีชื่อว่า “เมื่อ static-with-islands ดีกว่า full SSR”ถ้าหน้าส่วนใหญ่เหมือนกันสำหรับทุกคนมี dynamic bit นิดหน่อย static (หรือ cached) + islands ชนะ full SSR แทบทุกครั้ง: cache ได้ดีกว่า, load server น้อยกว่า, response เร็วกว่า เก็บ full on-demand rendering ไว้กับหน้าที่ต่างกันจริง ๆ ทุก request (dashboard ส่วนตัว, ผลค้นหา) การเลือกตัวเลือกที่ cache ได้ เองก็คือการตัดสินใจเรื่อง performance อยู่แล้ว
Cloudflare กับ adapter อื่น ๆ
หัวข้อที่มีชื่อว่า “Cloudflare กับ adapter อื่น ๆ”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 ที่ใช้ก่อนพึ่งพามัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- snippet สมมติว่า
routeRules: { '/x': { maxAge: 60 } }เพียงอย่างเดียวทำให้ browser cache response หรือเปล่า? พิสูจน์ข้างบนแล้วว่า: มันขับเคลื่อน headerCDN-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 ในโค้ดนี้
---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 ได้เองอยู่แล้ว
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 วินาที” พิสูจน์ข้างบนแล้วว่า: มันตั้ง headerCDN-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 แล้วจริง ๆ