TTFB Netlify

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.

TOML -- netlify.toml static caching configuration
# 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
TypeScript -- Astro static build for Netlify
// 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.

TypeScript -- Netlify Edge Function
// 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)
TypeScript -- Edge Function with Netlify Blobs
// 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.

TypeScript -- On-Demand Builder function
// 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)
TOML -- Redirect rules for On-Demand Builders
# 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.

TOML -- Optimal cache headers for Netlify CDN
# 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
Bash -- Purging Netlify CDN cache selectively
# 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.

TOML -- Function configuration in netlify.toml
[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
TypeScript -- Optimized function with connection reuse
// 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-Control headers set separately from browser Cache-Control
  • durable directive added to persist cache across deploys
  • Functions use esbuild bundler 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