Fix TTFB on Netlify: Optimize Time to First Byte
Netlify provides a powerful edge network and build system, but achieving optimal TTFB requires understanding its caching layers, function architecture, and edge capabilities. Common TTFB problems on Netlify include uncached SSR responses, Netlify Functions running in a single region, large function bundles causing cold starts, and missing CDN cache headers.
Netlify serves static assets from over 100 edge nodes globally with excellent TTFB. However, dynamic content rendered by Netlify Functions or server-side frameworks (Remix, Astro SSR, SvelteKit) still processes at a single origin. Netlify Edge Functions (powered by Deno) solve this by running at the edge, but they require careful configuration.
This guide covers five Netlify-specific optimizations: static-first architecture, Edge Functions for dynamic routes, On-Demand Builders for ISR-like caching, CDN cache header management, and function performance tuning. These techniques reduce Netlify TTFB from 800-2000ms to under 80ms for most pages.
Expected results
Following all steps in this guide typically produces these improvements:
Before
1.5s
TTFB (Poor) -- SSR functions in single region with no caching and cold start overhead
After
65ms
TTFB (Good) -- Edge functions with On-Demand Builders and proper CDN caching
Step-by-step fix
Adopt a static-first architecture
Netlify was built for static sites, and its CDN excels at serving pre-built HTML. For every page that does not need real-time data, generate static HTML at build time. Even pages that seem dynamic (product listings, blog archives) can often be statically generated with periodic rebuilds. Netlify triggers builds automatically from Git pushes or CMS webhooks.
# netlify.toml
[build]
publish = "dist"
command = "npm run build"
# Cache static HTML aggressively
[[headers]]
for = "/*.html"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
# Netlify CDN handles caching automatically for static files
# Setting max-age=0 ensures browsers revalidate while CDN serves
# Cache static assets with long TTL (hashed filenames)
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
# Cache images
[[headers]]
for = "/images/*"
[headers.values]
Cache-Control = "public, max-age=86400, stale-while-revalidate=604800"
# TTFB for static pages on Netlify CDN: 10-30ms globally
// astro.config.mjs
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
export default defineConfig({
output: 'hybrid', // Static by default, opt-in SSR per page
adapter: netlify(),
});
// src/pages/blog/[slug].astro
// Static: pre-generated at build time
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
// src/pages/search.astro
// Dynamic: server-rendered on each request
export const prerender = false; // Opt into SSR for this page only
Use Netlify Edge Functions for dynamic content
Netlify Edge Functions run on Deno at edge locations worldwide, providing 10-30ms execution time compared to 200-500ms for standard Netlify Functions. They are ideal for personalization, A/B testing, geolocation-based content, and lightweight API routes. The tradeoff is a Deno runtime (not full Node.js) with a 50ms CPU time limit per invocation.
// netlify/edge-functions/geolocation.ts
// Runs at the edge, closest to the user
import type { Context } from '@netlify/edge-functions';
export default async (request: Request, context: Context) => {
const { country, city } = context.geo;
// Personalize content based on location
const response = await context.next();
const html = await response.text();
const personalized = html.replace(
'{{location}}',
\`\${city}, \${country}\`
);
return new Response(personalized, {
headers: {
...Object.fromEntries(response.headers),
'Cache-Control': 'public, s-maxage=3600',
'Vary': 'Accept-Language',
},
});
};
export const config = { path: '/store/*' };
// TTFB: ~25ms (edge execution + personalization)
// netlify/edge-functions/product.ts
// Use Netlify Blobs for edge-accessible data
import { getStore } from '@netlify/blobs';
import type { Context } from '@netlify/edge-functions';
export default async (request: Request, context: Context) => {
const url = new URL(request.url);
const productId = url.pathname.split('/').pop();
// Netlify Blobs: edge-accessible key-value store
const store = getStore('products');
const product = await store.get(productId, { type: 'json' });
if (!product) {
return new Response('Not found', { status: 404 });
}
return Response.json(product, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
});
};
// TTFB: ~20ms (edge blob read + JSON response)
Implement On-Demand Builders for ISR-like caching
On-Demand Builders (ODB) are Netlify's equivalent of Incremental Static Regeneration. They generate a page on the first request and cache the result at the CDN edge for subsequent requests. This is perfect for large sites with thousands of pages where building every page at deploy time is impractical.
// netlify/functions/product-page.ts
import { builder } from '@netlify/functions';
const handler = async (event) => {
const slug = event.path.replace('/products/', '');
// Fetch product data from CMS or database
const product = await fetch(
\`\${process.env.CMS_URL}/api/products/\${slug}\`
).then(r => r.json());
if (!product) {
return { statusCode: 404, body: 'Product not found' };
}
// Render HTML
const html = renderProductPage(product);
return {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
// TTL: cache for 1 hour at CDN edge
'Netlify-CDN-Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
body: html,
};
};
// Wrap with builder() to enable CDN caching
export { handler: builder(handler) };
// First request: ~300ms (function execution + CMS fetch)
// Subsequent requests: ~15ms (served from CDN cache)
// After TTL: ~15ms (stale served, revalidation in background)
# netlify.toml
[[redirects]]
from = "/products/*"
to = "/.netlify/functions/product-page"
status = 200
[[redirects]]
from = "/categories/*"
to = "/.netlify/functions/category-page"
status = 200
# Combined with On-Demand Builders:
# /products/blue-sofa -> function generates HTML -> CDN caches
# Next visitor gets cached response in ~15ms
Optimize Netlify CDN cache headers
Netlify has two separate cache control systems: standard Cache-Control headers (for browsers) and Netlify-CDN-Cache-Control (for the Netlify CDN edge). Understanding this distinction is critical for optimal TTFB. The Netlify CDN respects its own header independently of what you send to browsers.
# netlify.toml
# HTML pages: short browser cache, long CDN cache
[[headers]]
for = "/*"
[headers.values]
# Browser: always revalidate
Cache-Control = "public, max-age=0, must-revalidate"
# Netlify CDN: cache for 1 hour, serve stale for 1 day
Netlify-CDN-Cache-Control = "public, s-maxage=3600, stale-while-revalidate=86400, durable"
# API responses: short cache everywhere
[[headers]]
for = "/api/*"
[headers.values]
Cache-Control = "public, max-age=60"
Netlify-CDN-Cache-Control = "public, s-maxage=60, stale-while-revalidate=300"
# The 'durable' directive persists cache across deploys
# Without it, every deploy invalidates all cached pages
# With it, pages remain cached unless you explicitly purge them
# Purge specific paths after content updates
# (instead of purging everything on deploy)
# Purge a single path
curl -X POST "https://api.netlify.com/api/v1/purge" \
-H "Authorization: Bearer $NETLIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"site_id": "your-site-id", "cache_tags": ["product-123"]}'
# In your function, tag responses for selective purging:
return {
statusCode: 200,
headers: {
'Netlify-Cache-Tag': 'product-123,category-furniture',
'Netlify-CDN-Cache-Control': 'public, s-maxage=86400, durable',
},
body: html,
};
Tune Netlify Functions for faster execution
Standard Netlify Functions run on AWS Lambda. Cold starts, large bundles, and unoptimized dependencies can add 500-2000ms to TTFB. Optimize by reducing bundle size, using connection pooling, and configuring appropriate memory and timeout settings.
[functions]
# Use esbuild for smaller, faster bundles
node_bundler = "esbuild"
# Include only necessary files
included_files = ["lib/**", "templates/**"]
# Exclude heavy dev dependencies
external_node_modules = ["sharp", "@prisma/client"]
[functions."product-page"]
# Increase memory for complex rendering
memory = 512 # Default is 128MB
[functions."api-search"]
# Keep memory low for simple API routes
memory = 128
// netlify/functions/api.ts
// Reuse connections across warm invocations
// Module-level: persists across warm invocations
let dbPool: Pool | null = null;
function getPool() {
if (!dbPool) {
dbPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5, // Limit connections in serverless
idleTimeoutMillis: 30000,
});
}
return dbPool;
}
export const handler = async (event) => {
const pool = getPool();
const { rows } = await pool.query(
'SELECT * FROM products WHERE slug = $1',
[event.queryStringParameters?.slug]
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Netlify-CDN-Cache-Control': 'public, s-maxage=300',
},
body: JSON.stringify(rows[0]),
};
};
// Cold start: ~400ms (connection init + query)
// Warm invocation: ~20ms (reused connection + query)
Quick checklist
- Static pages generated at build time with proper CDN cache headers
- Edge Functions enabled for latency-sensitive dynamic routes
- On-Demand Builders configured for large page sets with CDN caching
-
Netlify-CDN-Cache-Controlheaders set separately from browser Cache-Control -
durabledirective added to persist cache across deploys -
Functions use
esbuildbundler for smaller bundles - Database connection pooling configured for serverless functions
Frequently asked questions
Netlify Functions run on AWS Lambda in a single region with full Node.js support and up to 10s execution time. Edge Functions run on Deno at edge locations globally with sub-50ms cold starts but a 50ms CPU time limit. Use Edge Functions for lightweight personalization and redirects. Use standard Functions for heavy computation and database operations.
Netlify has a dual-header system: standard Cache-Control affects browsers and downstream caches, while Netlify-CDN-Cache-Control exclusively controls Netlify's edge caching. The durable directive is unique to Netlify -- it persists cached content across deploys, so a new deployment does not invalidate your entire cache. This is critical for sites with thousands of On-Demand Builder pages.
Netlify invalidates CDN cache on every deployment by default. The first visitor to each page triggers a cache miss, hitting your function or origin. Use the durable directive in Netlify-CDN-Cache-Control to persist cache across deploys. Also consider using Netlify background functions to pre-warm critical pages after deployment.
Netlify's equivalent is On-Demand Builders (ODB). Wrap your function handler with the builder() function from @netlify/functions, and it will cache the response at the CDN edge. Combined with Netlify-CDN-Cache-Control headers and stale-while-revalidate, you get ISR-like behavior with first-request generation and subsequent edge serving.
Netlify Analytics provides server-side request logs. For real-user TTFB monitoring, use the web-vitals JavaScript library to send TTFB data to your analytics platform. You can also use Netlify's Server Timing header to see the breakdown of edge processing, origin fetch, and function execution time for each request.
Related resources
Complete TTFB Guide
Deep dive into Time to First Byte -- thresholds, measurement, and optimization.
FixFix TTFB on Vercel
Vercel-specific TTFB optimizations with ISR and Edge Runtime.
FixEdge Functions for TTFB
How edge computing achieves sub-100ms TTFB globally.
FixCDN Optimization for LCP
CDN configuration strategies for faster content delivery.