MODRACXKENNETH D'SILVA

← Archive & Insights

Serverless Architecture & SEO Performance

Their Lighthouse scores were green the whole time their field data was red. Cold starts hit crawlers hardest and hide behind averages — here is why serverless breaks Core Web Vitals at the 75th percentile, and how caching fixes it.

By Kenneth D'SilvaReading Time: 23 min readCategory: Performance & Speed

1. The Migration That Made Everything Slower

A client moved their storefront off a pair of ageing VPS boxes onto a serverless platform. The pitch was the usual one: no servers to patch, scales to zero, scales to infinity, pay for what you use. All true.

Six weeks later their Core Web Vitals field data was worse than before. Not catastrophically — the median was fine, slightly better than the old setup. But the 75th percentile of Time to First Byte had gone from 480ms to 1.9 seconds, and since Google reports Core Web Vitals at the 75th percentile, the number that mattered had moved in the wrong direction.

Their lab tests all looked great. Every synthetic run was fast. The problem only existed for real users, and only for some of them, and there was no obvious pattern until we looked at the distribution rather than the average.

The cause was cold starts. On a low-traffic product page — and a catalogue of 14,000 products has a great many low-traffic pages — the function serving that route hadn't run in twenty minutes. The platform had to spin up a fresh execution environment, load the runtime, initialise the framework, open a database connection, and only then start rendering. That's the 1.9 seconds. For a customer it's an annoying pause. For Googlebot crawling the long tail of your catalogue, it's every single request.

Serverless is a genuinely good fit for a lot of ecommerce work. It is also the architecture where the gap between "fast in the lab" and "fast for real users" is widest, and where the SEO consequences are least obvious. This article is about closing that gap.

2. Three Different Things Called Serverless

Half the confused conversations I have about this come from people meaning different things by the word. Worth separating, because the performance characteristics are not remotely alike.

Regional functions. Your code runs in a container in a specific cloud region, spun up on demand. This is what most platforms mean by a serverless function by default. It has a full Node runtime, filesystem access, real memory. It also has cold starts measured in hundreds of milliseconds, and it lives in one region — so a customer in Sydney talking to a function in Virginia pays 200ms of latency per round trip regardless of how fast the code is.

Edge functions. Your code runs at the CDN's points of presence, close to the customer, usually in a lightweight JavaScript runtime rather than a full Node environment. Cold starts are effectively zero because the isolate model doesn't need to boot a container. The trade-off is a restricted runtime — no arbitrary native modules, tight CPU and memory limits, and often no direct TCP connections, which means your ordinary database driver won't work.

Managed platform hosting. Vercel, Netlify and similar, which sit on top of the above and add build-time rendering, caching, and routing. When someone says "we went serverless" this is usually what they mean, and the performance depends almost entirely on which of the layers underneath actually serves a given request.

That last point is the crux. On a well-configured platform, most requests never reach a function at all — they're served from cache or from pre-rendered static output, and they're extremely fast. The requests that do reach a function are the slow ones. So your performance profile isn't a single number; it's a bimodal distribution, and averages hide it completely.

3. Cold Starts, Quantified

A cold start is the work done before your code runs: allocating an execution environment, loading the runtime, and initialising your application. Then your code runs, and if it's the first execution it does its own expensive setup — importing modules, reading config, establishing a database connection.

Rough magnitudes, which vary by platform and by what you've loaded:

EnvironmentTypical cold startWarm
Edge runtime (V8 isolate)under 10ms~0ms
Node regional function, small bundle150–400ms~0ms
Node regional function, large framework500ms–1.5s~0ms
Same, plus a fresh database connection+50–300ms~0ms
JVM or .NET regional function1–4s~0ms

Two things follow that are worth internalising.

Two things follow.

The warm case is genuinely excellent. A warm function has no penalty at all. This is why lab testing lies to you: you run the test three times, the first is slow, and you use the median of the last two.

Second, the variables you control most cheaply are bundle size and initialisation work. A function importing your whole ORM, your validation library, your date library and your analytics SDK pays for all of it on every cold start. Moving a heavyweight import inside the handler so it only loads on the paths that need it is often worth several hundred milliseconds.

// Loaded on every cold start, even for requests that never use it
import { PDFDocument } from 'pdf-lib';
import { heavyAnalytics } from './analytics';

export async function handler(req) {
  if (req.url.endsWith('/invoice.pdf')) {
    return renderInvoice(req);
  }
  return renderPage(req);
}

// Better: pay for it only on the route that needs it
export async function handler(req) {
  if (req.url.endsWith('/invoice.pdf')) {
    const { PDFDocument } = await import('pdf-lib');
    return renderInvoice(req, PDFDocument);
  }
  return renderPage(req);
}

Measure before you assume. Most platforms report initialisation duration separately from execution duration, and the split tells you immediately whether your problem is boot time or your own code.

4. What Actually Goes Into a Cold Start

Since cold start duration is mostly a function of what you load, it pays to know what the expensive parts are. In rough order of how often they turn out to be the culprit:

Bundle size. Every megabyte of JavaScript has to be fetched, parsed and evaluated before your handler runs. A function bundle of 8MB is not unusual on a framework-heavy application, and it is worth several hundred milliseconds against a 1MB one. Most platforms report bundle size per function after a build; if yours is growing and nobody is watching, it is growing.

Transitive dependencies you did not choose. A validation library that pulls a date library that pulls a locale bundle covering every language on earth. The tooling to find these is good and underused:

# What is actually in the bundle, largest first
npx esbuild src/handler.js --bundle --platform=node --analyze --outfile=/dev/null 2>&1 | head -30

# Which dependency dragged in a package you did not expect
npm ls date-fns-tz --all

Top-level side effects. Code that runs on import rather than on call. Reading a config file, building a route table, compiling a schema, instantiating an SDK client. Each is small; together they are frequently the largest single component of init time, and they are invisible because nobody thinks of an import as work.

Secrets fetched at boot. A call to a secrets manager on every cold start adds a network round trip to the slowest possible moment. Inject secrets as environment variables at deploy time where the platform supports it, or cache the fetched value at module scope so warm invocations skip it.

The runtime itself. The floor you cannot go below. This is why edge runtimes are dramatically faster to start — a V8 isolate does not boot a container or a Node process, it just instantiates a sandbox, and the difference is two orders of magnitude.

A useful discipline: set a budget for init duration — 250ms is a reasonable target for a Node function — and alert when a deploy exceeds it. Cold start regressions arrive quietly, one dependency at a time, and nobody notices until a quarter has passed and the field data has drifted.

5. Why This Hits SEO Specifically

Slow pages are bad for everyone, but serverless cold starts have three effects that are particular to search.

Core Web Vitals are measured at the 75th percentile. Not the median. If 70% of your requests are warm and instant and 30% are cold and slow, your median looks wonderful and your reported score reflects the cold tail. This is exactly the trap the client at the top of this article fell into, and it's why "our lab scores are green" is not an answer to "our field data is red".

Crawlers hit your cold paths disproportionately. Real customers cluster on popular products, keeping those functions warm. Googlebot systematically works through your entire catalogue, including the 11,000 products nobody visited this month. Almost every crawler request is a cold request. So the experience Google measures when crawling is close to your worst case, not your average.

Slow responses reduce crawl rate. Google adjusts how aggressively it crawls based on how your server responds. Consistently slow responses lead it to back off — fewer pages crawled per day, slower discovery of new products, slower reflection of price and stock changes. On a large catalogue that's a real commercial cost, and it compounds quietly.

You can see this directly. In Search Console's Crawl Stats report, look at average response time and total crawl requests over time. A serverless migration that hurt crawl performance usually shows as response time climbing and request volume falling in the same week.

6. Rendering Strategy Is the Real Lever

Before optimising function cold starts, ask whether the page needs a function at all. This is where the large wins are, and it's a content-architecture question rather than an infrastructure one.

Static generation

Render at build time, serve from the CDN. TTFB in the tens of milliseconds, no cold start, no database, nothing to go wrong. For content that changes rarely — blog posts, category landing pages, brand pages, help content — this is unambiguously correct.

The limit is build time. Statically generating 14,000 product pages means a build that takes an hour, and a price change means either rebuilding everything or accepting staleness. Which leads to:

Incremental regeneration

Generate a page on first request, cache it, serve the cached copy to everyone else, and refresh it in the background on a schedule or on demand. The first visitor pays the cold cost; nobody else does.

// Next.js App Router: revalidate this route's cache every 5 minutes
export const revalidate = 300;

// Pre-render the products that matter; let the tail generate on demand
export async function generateStaticParams() {
  const top = await getBestSellingProductSlugs(500);
  return top.map(slug => ({ slug }));
}

This is the pattern that fits ecommerce best, and the nuance is which pages you pre-render. Pre-render your best sellers and your main categories; let the long tail generate on demand. That way real customers almost never hit a cold generation, and the ones who do are looking at a product nobody buys.

The important addition for search: pair it with on-demand invalidation so a price or stock change purges the affected page immediately rather than waiting out the revalidation window. Serving a stale price is worse than serving a slow page.

// Called from your PIM/ERP webhook when a product changes
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request) {
  const { sku, categoryIds } = await request.json();
  revalidatePath(`/product/${sku}`);
  for (const id of categoryIds) revalidateTag(`category-${id}`);
  return Response.json({ revalidated: true });
}

Server rendering

Genuinely required for pages that are different for every visitor: cart, checkout, account, anything with customer-specific pricing. These pages are also the ones you don't want indexed, which is convenient — their TTFB doesn't feed your Core Web Vitals in any way that matters for search, because Google isn't crawling them.

The mistake is obvious once named: server-rendering pages that don't need it. A product page that's identical for everyone except a "recently viewed" strip does not need to be dynamic; it needs to be static with that strip filled in client-side.

Streaming

Worth knowing about because it changes the shape of the problem. Streaming SSR sends the shell of the page immediately and streams the slower parts as they resolve, so TTFB reflects the shell rather than the slowest query.

Be careful about what you stream on an indexable page, though. Content that arrives late in the stream is generally still seen by Googlebot, but content that depends on client-side JavaScript after the stream completes is a different matter. Keep anything that matters for search — product name, price, description, structured data — in the initial payload.

7. Serving Search and Faceted Navigation

Category pages with filters are where the static-versus-dynamic question gets genuinely hard, and where I see the most expensive mistakes on serverless storefronts.

The problem is combinatorial. A category with five filter dimensions produces thousands of URL permutations, and pre-rendering all of them is neither possible nor desirable. Rendering all of them on demand means a function invocation for every filter click.

What works, roughly in order of preference:

Statically render the canonical category page; handle filters client-side. The unfiltered category is what search engines care about and what most visitors land on. Filtering is an interaction, and interactions can be client-side against a search API. This keeps the indexable page free of function invocations entirely.

Pre-render the filter combinations that have search demand. Usually a small set — "black leather sofas", "waterproof walking boots size 9" — identified from your own site-search logs and from keyword research. These deserve to be real indexable pages with their own titles and copy. The other 4,000 permutations do not.

Keep the rest out of the index. This is the part that gets skipped, and it costs both crawl budget and function spend. Uncontrolled facet URLs are one of the classic ways to waste a crawl allowance on a large catalogue.

// Facet combinations beyond the curated set: canonical to the base
// category, and keep them out of the index.
export function generateMetadata({ params, searchParams }) {
  const curated = isCuratedFacet(params.category, searchParams);
  return {
    alternates: { canonical: curated ? currentUrl(params, searchParams) : `/category/${params.category}` },
    robots: curated ? undefined : { index: false, follow: true }
  };
}

Cache aggressively on a normalised key. Sort filter parameters into a canonical order before they reach the cache key, so ?colour=black&size=9 and ?size=9&colour=black are one cache entry rather than two. This one-line change routinely doubles hit rate on faceted routes.

Site search itself is the one place I'd argue for a dedicated search service rather than querying your primary database from a function. Search queries are expensive, unpredictable, and hostile to caching, which is the worst possible combination for per-invocation billing.

8. The Database Problem

This catches nearly every team migrating a traditional application, and it's the one that produces outages rather than just slowness.

A traditional server keeps a connection pool: ten or twenty connections, reused across thousands of requests. Serverless functions scale horizontally, and each instance wants its own connection. Traffic spike to 500 concurrent function instances and you have 500 connections attempting to open against a database configured for 100.

The symptoms are ugly: connection refused errors under load, which is exactly when you can least afford them, and a database that spends its CPU on connection setup rather than queries.

Three fixes, in order of how much I'd reach for them:

An external connection pooler. RDS Proxy, PgBouncer, or your provider's equivalent sits between the functions and the database, maintaining a small real pool and multiplexing many client connections onto it. This is the standard answer for a conventional database and it works well.

An HTTP-based data layer. Databases with an HTTP API — or a driver that speaks HTTP rather than raw TCP — sidestep the problem entirely, and are also the only option from an edge runtime that can't open TCP sockets.

Don't query the database on the hot path. The best request is one that never reaches your data layer. Static generation and cached responses mean the database only sees traffic during regeneration, which is a fraction of the volume.

And a smaller detail that matters more than it should: initialise the client outside the handler so warm invocations reuse it, rather than opening a fresh connection every request.

// Module scope — survives between warm invocations on the same instance
let client;

function getClient() {
  if (!client) {
    client = createClient({
      connectionString: process.env.DATABASE_URL,
      max: 1,              // one connection per instance; the pooler does the rest
      idleTimeoutMillis: 30_000,
      connectionTimeoutMillis: 5_000
    });
  }
  return client;
}

export async function handler(req) {
  const db = getClient();
  // ...
}

max: 1 looks wrong to anyone used to server-side pooling, and it's correct here. Each function instance handles one request at a time, so a pool of ten per instance multiplies your connection count by ten for no benefit.

9. Region, and the Latency You Can't Optimise Away

Serverless makes it easy to forget where your code physically runs. Then a customer in Singapore requests a page from a function in Ireland, and every database round trip crosses an ocean twice.

The arithmetic is unforgiving. If your function is 150ms from your database and the page makes six sequential queries, that's 900ms of pure network time before any work happens. The fix isn't a faster function; it's fewer round trips, or co-location.

Three practical rules.

Put the function in the same region as the database. Not near the user — near the data. If a page needs the database, latency to the database dominates. Some platforms default to deploying functions in a region unrelated to where you put your database, and nothing warns you.

Use the edge only for work that needs no origin data. Edge functions are excellent for redirects, A/B assignment, geo-routing, header manipulation, bot detection, and personalisation from a cookie. They are a poor fit for anything that has to reach a single-region database, because you've moved the compute closer to the user and further from the data.

Collapse sequential queries. Six queries in sequence is six times the latency of one. Parallelise what's independent, and push joins into the database rather than assembling in application code.

// Three sequential round trips
const product = await getProduct(sku);
const stock = await getStock(product.id);
const reviews = await getReviews(product.id);

// One round trip's worth of latency
const product = await getProduct(sku);
const [stock, reviews] = await Promise.all([
  getStock(product.id),
  getReviews(product.id)
]);

10. Keeping Functions Warm, and Why It's Usually the Wrong Fix

The obvious response to cold starts is to prevent them. Two mechanisms exist, and both deserve scepticism.

Provisioned concurrency keeps a set number of instances initialised and ready. It works, it's supported, and it costs money continuously whether or not the capacity is used — which is a direct contradiction of the reason most teams chose serverless. If you need thirty warm instances at all times, you've described a server, and a server is cheaper.

Warming pings — a scheduled job hitting your functions every few minutes — are a folk remedy that mostly doesn't work. A ping keeps one instance warm. It does nothing for the second concurrent request, which spawns a cold instance anyway. It's also a per-route problem: on a storefront with hundreds of route variants, keeping them all warm means a great deal of pointless traffic, and you're paying for every invocation.

Both are treating the symptom. The better questions are: does this page need a function at all, and can the response be cached so the function runs once per revalidation window rather than once per request? A page served from cache has no cold start because no code ran.

Where I do use provisioned concurrency: a single, genuinely dynamic, genuinely critical path — usually checkout — where the traffic is steady enough to justify the spend and the cost of a slow response is a lost order. Not across the board.

11. Caching Is What Makes This Work

Everything above converges here. Serverless performance is a caching story wearing infrastructure clothes.

The layers, from cheapest to most expensive per request:

Static output at the CDN. No compute at all. Tens of milliseconds. This should serve the large majority of your indexable pages.

Cached function output. The function ran once; everyone else gets the stored response. Use stale-while-revalidate so a customer never waits for a regeneration:

Cache-Control: public, s-maxage=300, stale-while-revalidate=86400

That header is doing a lot of work. For five minutes the cached copy is fresh. For the next day it's stale but still served immediately, while a background refresh happens. The visitor never waits, and the function runs on a schedule rather than on demand. On a serverless storefront this single header is frequently worth more than every code optimisation combined.

A cold function invocation. The expensive case, and the one you're trying to make rare.

The cache key is where this goes wrong. Include something unnecessary — a session cookie, a tracking parameter — and your hit rate collapses because every visitor has a unique key. Normalise aggressively: strip utm_*, gclid, fbclid and their relatives before the key is computed, and vary only on things that genuinely change the response, such as currency or language.

Check your actual hit rate rather than assuming. A storefront where the CDN reports 60% hits has a caching problem, and finding it is usually more valuable than any amount of function tuning.

12. What Crawlers See

A few serverless-specific behaviours worth verifying rather than assuming.

Rendering. Google renders JavaScript, but rendering is queued and can lag. Anything essential for indexing should be in the HTML the server sends. On a serverless React application that means server components or server rendering for product data, not a client-side fetch after hydration. Test with the URL Inspection tool's rendered HTML, not with your own browser.

Soft 404s. A common serverless bug: a missing product renders a "not found" page with a 200 status because the framework's error path wasn't wired to set the status code. Google indexes those as thin content. Verify with curl, not with your eyes.

curl -sI https://shop.example.com/product/does-not-exist | head -1
# HTTP/2 404   <- correct
# HTTP/2 200   <- soft 404, fix this

Timeouts under crawl load. Googlebot crawling hundreds of long-tail URLs can spawn many concurrent cold functions, and functions that are fine individually can hit concurrency or database connection limits in aggregate. If Search Console shows a spike in server errors that doesn't correspond to customer traffic, this is a likely cause.

Preview deployments. Platforms that give every branch a public URL will happily let those URLs be crawled and indexed, producing duplicate content across dozens of hostnames. Every preview environment needs X-Robots-Tag: noindex and ideally HTTP authentication.

// middleware — keep non-production deployments out of the index
export function middleware(request) {
  const res = NextResponse.next();
  if (process.env.DEPLOY_ENV !== 'production') {
    res.headers.set('X-Robots-Tag', 'noindex, nofollow');
  }
  return res;
}

13. The Cost Conversation

Worth having early, because the bill has a shape that surprises people.

Serverless pricing is per invocation and per unit of compute time. That's excellent for spiky, low-baseline workloads — a store doing 40 orders a day genuinely pays almost nothing. It's poor for steady high traffic, where a reserved instance is far cheaper per request.

The costs that catch teams out are rarely the compute:

Bandwidth. Often the largest line, and priced per gigabyte. Unoptimised product images will dominate your bill on an image-heavy catalogue.

Image optimisation transforms. Priced per unique source image on most platforms. A catalogue refresh that changes 14,000 images is a bill.

Function invocations from bots. Crawlers, scrapers and monitoring tools generate real invocations that you pay for. On a large catalogue, crawler traffic can be a substantial fraction of your function spend — another argument for serving static output, which costs a fraction of an invocation.

Build minutes. A site that statically generates a large catalogue on every deploy burns build time proportional to catalogue size.

None of this is an argument against serverless. It's an argument for modelling the bill against your actual traffic shape before committing, and for treating cache hit rate as a cost metric as well as a performance one.

14. Observability

You cannot fix a bimodal latency distribution by watching an average. Three things to instrument.

Split cold from warm. Most platforms expose an init duration; if yours doesn't, set a module-scope flag:

let warm = false;

export async function handler(req) {
  const wasCold = !warm;
  warm = true;
  const started = Date.now();

  const response = await render(req);

  logger.info('request', {
    path: new URL(req.url).pathname,
    cold: wasCold,
    ms: Date.now() - started
  });

  return response;
}

Then chart cold and warm as separate series. The cold p75 is the number that predicts your field data.

Collect real user metrics. Lab testing cannot reproduce cold starts reliably, so field data is not optional here:

// Report TTFB and LCP from real sessions
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    navigator.sendBeacon('/_rum', JSON.stringify({
      metric: entry.entryType === 'navigation' ? 'ttfb' : 'lcp',
      value: entry.entryType === 'navigation'
        ? entry.responseStart - entry.requestStart
        : entry.startTime,
      path: location.pathname
    }));
  }
}).observe({ type: 'navigation', buffered: true });

Watch cache hit rate by route. A route with a collapsed hit rate is a route serving cold functions to everyone, and it will show up in your Core Web Vitals weeks before you find it by reading logs.

15. A Migration Done Properly

Returning to the client from the opening, because the fix is instructive.

The diagnosis. Everything was server-rendered on demand. 14,000 product pages, all dynamic, all hitting the database on every request, no output caching. The platform was doing exactly what it was told; it had been told to run a function for every page view.

What we changed. Product and category pages moved to incremental regeneration with a five-minute revalidation window and webhook-driven invalidation from their PIM. The top 800 products were pre-rendered at build time. Cart, checkout and account stayed dynamic, which is correct — and they're noindex, so their TTFB doesn't affect search at all.

The function region moved to match the database region, which someone had never set and which had defaulted to a continent away from their data.

Six sequential queries on the product page became two parallel ones. That alone took roughly 400ms off a cold render.

Connections went through a pooler, and the client moved to module scope with max: 1.

The result. TTFB at the 75th percentile went from 1.9s to 210ms. Not because functions got faster — cold starts were still around 800ms — but because roughly 94% of requests stopped invoking a function at all. Search Console crawl rate recovered over about three weeks and then exceeded the pre-migration level, since responses were now faster than the old VPS had managed.

Their platform bill dropped about 40%. Nobody had asked for that.

Invocations fell by more than traffic did, which is the whole story in one line: they had been paying to compute the same page over and over.

What I'd flag. None of this was serverless-specific expertise. It was caching, query batching, and putting compute next to data — the same things that would have helped on the old VPS. Serverless didn't cause the problem; it removed the accidental caching the old stack had been doing and exposed how much the application depended on it.

16. Migrating Without a Bad Fortnight

Most of the pain I have watched teams go through was schedule pain rather than technical pain. A sequence that avoids it:

Measure the old stack first. Two weeks of field data — TTFB, LCP, crawl stats — before anything changes. Without a baseline, every post-migration complaint becomes an argument about whether things used to be better. They usually were, in at least one respect, and you want to know which.

Decide the rendering strategy per template before writing code. A table with one row per template and a column saying static, incremental, or dynamic, with a reason. This is a thirty-minute exercise that determines almost all of your eventual performance, and skipping it is how everything ends up dynamic by default.

Move one template first. Blog or content pages are ideal: low commercial risk, genuinely static, and they exercise the whole build and deploy path. You learn how the platform behaves on something that cannot cost you an order.

Keep the old stack serving until field data confirms the new one. Not lab data. Run both, split traffic if you can, and compare the 75th percentile over at least a week. Cold start behaviour under real traffic patterns is not reproducible any other way.

Watch crawl stats for a month afterwards. Crawl rate responds slowly. A migration that looks fine on day three can show a declining crawl trend by week three, and by then the deploy is old news and nobody connects the two.

One thing worth agreeing before you start: who owns cache invalidation. It sits between the ecommerce platform, the front end, and whatever holds product data, which means it belongs to nobody by default. On every migration where invalidation was somebody's explicit job, it worked. On the ones where it was assumed, stale prices reached customers.

17. When Serverless Is the Wrong Choice

Because the honest version of this article includes the cases where I'd advise against it.

Steady high traffic with a stable baseline. If you're serving consistent load around the clock, reserved capacity is cheaper and simpler. The scale-to-zero benefit is worthless when you never scale to zero.

Long-running work. Functions have execution limits. Report generation, bulk catalogue imports, video processing and large exports fit badly and end up as awkward chunked pipelines. Run them on something that doesn't have a timeout.

A heavy legacy monolith. A large PHP application on Lambda is possible and rarely pleasant. The cold start cost of booting the framework dominates, and you spend your effort fighting the model rather than getting value from it.

Tight WebSocket or long-polling requirements. Persistent connections are an awkward fit for a request-scoped compute model. There are managed services for this; a plain function is not one of them.

When nobody on the team wants to own the caching strategy. This is the real one. Serverless without a deliberate caching layer is slower and more expensive than the boring server it replaced. If the migration plan doesn't have a caching design in it, the migration plan isn't finished.

18. Questions That Come Up

"Does Google penalise serverless sites?" No. There's no architecture signal. What exists is a response-time signal and a Core Web Vitals signal, and cold starts affect both. Fix the latency and the architecture is invisible.

"Should I just put everything at the edge?" Only the work that needs no origin data. Edge compute next to the user but far from the database is often slower than regional compute next to the database, and the constrained runtime will fight you.

"Our Lighthouse scores are perfect but field data is bad." Textbook cold-start signature. Lighthouse warms the function on its first run and reports the warm case. Trust field data; treat lab scores as a debugging tool, not a measurement.

"Is ISR safe for prices?" Only with on-demand invalidation wired to your source of truth. A revalidation window alone means serving stale prices for its duration, which is a customer-service problem and in some jurisdictions a legal one. Invalidate on change; use the window as a backstop.

"How do I test cold starts?" Deploy, wait past the idle timeout — fifteen minutes is usually enough — and request a route nobody else is hitting. Or request many distinct long-tail URLs in parallel so the platform has to spawn fresh instances. Testing your homepage tells you nothing, because it's never cold.

"Do I need to worry about this on Shopify?" Not in this form. Shopify runs their own infrastructure and you don't manage functions or cold starts. It becomes relevant if you build a custom storefront on Hydrogen or Next.js against their APIs, at which point everything here applies to your layer.

19. Where I'd Start

Open Search Console's Crawl Stats and look at average response time over the last ninety days. If it's above about 600ms, or if it stepped up on a date you recognise as a deploy, you have the problem this article describes.

Then find your cache hit rate. If most requests are invoking functions, that's the whole finding — everything else is downstream of it.

Then ask, for each of your main templates, whether it genuinely needs to be dynamic. On most storefronts the honest answer is that only cart, checkout and account do. Product and category pages are the same for everyone until you personalise them, and personalisation almost always belongs client-side.

Fix that and you've addressed most of it. Cold start tuning, bundle trimming, provisioned concurrency — all real techniques, all worth less than moving pages out of the function path entirely.

One habit worth building while you do it: whenever someone proposes making a page dynamic, ask what specifically differs per visitor and whether that thing could be filled in after the page arrives. The answer is usually a stock indicator, a personalised recommendation strip, or a logged-in header — three small pieces of a page that do not justify making the whole document uncacheable. Serverless punishes that trade harder than a traditional server did, because on a traditional server the cost was some CPU you had already paid for, and here the cost is a cold start in front of a customer.

The thing worth remembering from the migration at the top: their lab scores were green the entire time the field data was red. Serverless is the architecture where those two numbers diverge most, and the one where believing the wrong number costs you the most.