Why Headless CMS is Fast: Measuring Real Performance by Delegating Search and Filtering to Contentful

Why Headless CMS is Fast: Measuring Real Performance by Delegating Search and Filtering to Contentful

It is sometimes said that headless CMS is fast. In this article, we will compare the method of searching and filtering article lists on the application side with the method of doing it on the headless CMS side, using Contentful as an example, and measure the actual time to completion.
2026.01.12

This page has been translated by machine translation. View original

Introduction

Headless CMS is often said to be fast. However, explanations of what exactly is fast tend to be conceptual.

For instance, when creating article listings on a website, requirements often include adding search and filtering capabilities without sacrificing display speed. Additionally, there's often a requirement to design for future scalability as the number of articles increases. In this context, what's important is where the filtering process is executed. Besides receiving all entries on the application side and filtering them there, having "the data holder" filter and return only what's needed is another approach. The latter works well with the microservices concept in web design. The headless CMS that holds the data takes responsibility for search and filtering, while the calling side requests only what's necessary. (This is similar to leaving the WHERE clause to the database in SQL.)

get only required

The aim of this article is to use Contentful as an example to confirm the benefits of delegating filtering processes to "the data holder" with actual measurements.

What is Headless CMS

A headless CMS is a content management system that separates content management from its presentation. Content is edited in an admin interface, and web applications retrieve and display the content through APIs.

What is Contentful

Contentful is a service that provides headless CMS as SaaS. You can define Content Models in the management interface, create Entries, and publish them. Contentful's Content Delivery API (CDA) is a read-only REST API for retrieving published content. Applications retrieve JSON via HTTP and can control search and returned fields via query parameters.

Target Audience

  • Those who want to understand the benefits of headless CMS based on actual measurements
  • Those who want to add search and filtering to article listings but are unsure about design principles
  • Those concerned about how retrieving all entries and filtering on the application side might impact performance as content grows

References

Verification Approach

In this article, we'll use the following two CDA features:

  • Filtering: Field-level full-text search using fields.title[match]
  • Field selection: Response field filtering using select

To confirm the strengths of headless CMS, we'll conduct benchmarks. We'll compare the time until processing completion. Processing completion here means the time until HTTP retrieval and JSON parsing are finished, and the data is available as an array.

Comparison Targets

We'll compare two methods:

  • Method A
    Retrieve all entries and filter on the application side. Retrieve 100 entries at once and use includes judgment on title on the application side. We also retrieve the unnecessary body field for listing.

  • Method B
    Filter on the headless CMS side and retrieve only necessary fields. Use fields.title[match] for headless CMS-side search and return only title and slug using select.

Test Data Insertion

CDA delivers published content. Therefore, we need to make sure the Entries for comparison are published.

Creating Content Type

The content type will be blogPost with the following three fields:

  • title (Short text)
  • slug (Short text)
  • body (Long text)

content model

body is not used in listings. However, to make differences more noticeable, we'll make body larger in the test data.

Setup

To run TypeScript, we'll use tsx.

npm init -y
npm i contentful-management dotenv
npm i -D tsx typescript @types/node

Environment Variable Setting

Create .env.

CF_SPACE_ID=xxxxx
CF_ENV=master
CF_LOCALE=en-US

CF_MANAGEMENT_TOKEN=xxxxx
CF_CONTENT_TYPE=blogPost

CF_TOTAL=100
CF_FROM=1
CF_HIT_COUNT=50

CF_KEYWORD=NEEDLE
CF_BODY_CHARS=20000

Please adjust CF_LOCALE to match your Space's Settings > Locales content.

Locale

Also, create CF_MANAGEMENT_TOKEN from Space Settings > CMA tokens.

CMA tokens

Data Insertion Script (seed.ts)

import "dotenv/config";
import { createClient } from "contentful-management";

const {
  CF_SPACE_ID,
  CF_ENV = "master",
  CF_LOCALE = "en-US",
  CF_MANAGEMENT_TOKEN,
  CF_CONTENT_TYPE = "blogPost",
  CF_TOTAL = "100",
  CF_FROM = "1",
  CF_HIT_COUNT = "50",
  CF_KEYWORD = "NEEDLE",
  CF_BODY_CHARS = "20000",
} = process.env;

if (!CF_SPACE_ID || !CF_MANAGEMENT_TOKEN) {
  throw new Error("CF_SPACE_ID and CF_MANAGEMENT_TOKEN are required");
}

const TOTAL = Number(CF_TOTAL);
const FROM = Number(CF_FROM);
const HIT_COUNT = Number(CF_HIT_COUNT);
const BODY_CHARS = Number(CF_BODY_CHARS);

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function makeBody(n: number): string {
  const chunk = "Lorem ipsum dolor sit amet. ";
  let s = "";
  while (s.length < n) s += chunk;
  return s.slice(0, n);
}

async function main(): Promise<void> {
  const client = createClient({ accessToken: CF_MANAGEMENT_TOKEN });
  const space = await client.getSpace(CF_SPACE_ID);
  const env = await space.getEnvironment(CF_ENV);

  const body = makeBody(BODY_CHARS);

  for (let i = FROM; i <= TOTAL; i++) {
    const slug = `post-${String(i).padStart(4, "0")}`;

    // `fields.title[match]` is full-text search, not strict partial matching.
    // For stable hits in this test, we place the keyword at the beginning of the title.
    const title = i <= HIT_COUNT ? `${CF_KEYWORD} post ${i}` : `post ${i}`;

    const entry = await env.createEntry(CF_CONTENT_TYPE, {
      fields: {
        title: { [CF_LOCALE]: title },
        slug: { [CF_LOCALE]: slug },
        body: { [CF_LOCALE]: body },
      },
    });

    await entry.publish();

    // Wait to avoid rate limits.
    await sleep(200);
    process.stdout.write(".");
  }

  process.stdout.write("\nDone.\n");
}

main().catch((e) => {
  console.error(e);
  process.exitCode = 1;
});

Run it:

npx tsx seed.ts
....................................................................................................
Done.

Verify that the data has been inserted in the console.

seed.ts result

Measuring Time to Completion

We'll compare A and B under the same conditions and measure the time to completion. Although CDA has rate limits, they shouldn't be an issue with the small number of measurements we're making.

Environment Variable Setting

Add to .env:

CF_DELIVERY_TOKEN=xxxxx
CF_RUNS=5

To obtain CF_DELIVERY_TOKEN, create an API key from Space Settings > API keys. Use the Content Delivery API value.

API keys

Measurement Script (bench.ts)

Since retrieving all entries may hit response size limits, we'll use pagination with skip and limit.

Add to .env:

CF_LIMIT=100
CF_PAGE_SIZE=100

Here's bench.ts:

import "dotenv/config";
import { performance } from "node:perf_hooks";

type EntryLike = {
  fields?: {
    title?: string;
    slug?: string;
  };
};

const {
  CF_SPACE_ID,
  CF_ENV = "master",
  CF_DELIVERY_TOKEN,
  CF_CONTENT_TYPE = "blogPost",
  CF_KEYWORD = "NEEDLE",
  CF_RUNS = "5",
  CF_LIMIT = "100",
  CF_PAGE_SIZE = "100",
} = process.env;

if (!CF_SPACE_ID || !CF_DELIVERY_TOKEN) {
  throw new Error("CF_SPACE_ID and CF_DELIVERY_TOKEN are required");
}

const RUNS = Number(CF_RUNS);
const LIMIT = Number(CF_LIMIT);
const PAGE_SIZE = Number(CF_PAGE_SIZE);

const baseUrl = `https://cdn.contentful.com/spaces/${CF_SPACE_ID}/environments/${CF_ENV}/entries`;
const authHeader = { Authorization: `Bearer ${CF_DELIVERY_TOKEN}` };

async function fetchJson(url: string): Promise<any> {
  const res = await fetch(url, { headers: authHeader });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

function urlWith(params: Record<string, string | number>): string {
  const u = new URL(baseUrl);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v));
  return u.toString();
}

function median(values: number[]): number {
  const a = [...values].sort((x, y) => x - y);
  const mid = Math.floor(a.length / 2);
  return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2;
}

async function runA(): Promise<{ ms: number; hits: number }> {
  const t0 = performance.now();

  const itemsAll: EntryLike[] = [];

  for (let skip = 0; skip < LIMIT; skip += PAGE_SIZE) {
    const limit = Math.min(PAGE_SIZE, LIMIT - skip);

    const url = urlWith({
      content_type: CF_CONTENT_TYPE!,
      limit,
      skip,
      order: "sys.createdAt",
    });

    const data = await fetchJson(url);
    const items = (data.items ?? []) as EntryLike[];
    itemsAll.push(...items);
  }

  const hits = itemsAll.filter((e) => (e.fields?.title ?? "").includes(CF_KEYWORD!)).length;

  const t1 = performance.now();
  return { ms: t1 - t0, hits };
}

async function runB(): Promise<{ ms: number; hits: number }> {
  const url = urlWith({
    content_type: CF_CONTENT_TYPE!,
    limit: LIMIT,
    "fields.title[match]": CF_KEYWORD!,
    select: "sys.id,fields.title,fields.slug",
  });

  const t0 = performance.now();
  const data = await fetchJson(url);

  const items = (data.items ?? []) as EntryLike[];
  const t1 = performance.now();

  return { ms: t1 - t0, hits: items.length };
}

async function bench(
  name: string,
  fn: () => Promise<{ ms: number; hits: number }>,
): Promise<void> {
  const times: number[] = [];
  let hits = 0;

  for (let i = 0; i < RUNS; i++) {
    const r = await fn();
    times.push(r.ms);
    hits = r.hits;
  }

  console.log(`- ${name}`);
  console.log(`  - median: ${Math.round(median(times))} ms`);
  console.log(`  - runs: ${times.map((t) => Math.round(t)).join(", ")} ms`);
  console.log(`  - hits: ${hits}`);
}

async function main(): Promise<void> {
  console.log("# Results (ms)");
  await bench("A: Retrieve all and filter on application side", runA);
  await bench("B: Filter on headless CMS side + select", runB);
}

main().catch((e) => {
  console.error(e);
  process.exitCode = 1;
});

Run it:

npx tsx bench.ts

The output was:

# Results (ms)
- A: Retrieve all and filter on application side
  - median: 28 ms
  - runs: 495, 30, 28, 28, 20 ms
  - hits: 50
- B: Filter on headless CMS side + select
  - median: 10 ms
  - runs: 295, 11, 9, 10, 9 ms
  - hits: 50

Results

Comparing the median values, B completed processing in less time than A.

Retrieval Method Time (ms, median)
A: Retrieve all and filter on application side 28
B: Filter on headless CMS side + select 10

Based on the median values, B was about 2.8 times faster than A.

Note that for both methods, the first run has a much larger value. This is because DNS resolution, TLS handshake, and HTTP connection establishment are concentrated in the first run. In this article, we use the median value to avoid this influence.

Additional Experiment: Increasing Article Count to Observe Scaling

With only 100 articles for comparison, it's hard to determine whether the difference is coincidental or if it's a structural difference that widens as the number of articles increases. In this additional experiment, we'll increase the article count to 500 and 1000 and observe how the processing time scales. The goal is to verify with actual measurements whether A's growth approaches O(n) and whether B's growth remains closer to O(k) as n increases. (Here n is the total number of articles, and k is the number of articles that match the search criteria.)

Predefined Conditions

To make this observation clearer, we'll fix the hit count k. Specifically, we'll always have 50 articles containing the keyword in the title and add only articles without the keyword. This makes it easier to compare how processing time increases as article count n increases.

Adding More Data

Assuming we already have 100 articles, we'll add more to reach 1000 articles, starting from the 101st article. Our seed.ts already allows specifying the creation range with CF_FROM and CF_TOTAL, and fixing the number of articles with keywords using CF_HIT_COUNT.

Change .env to:

CF_TOTAL=1000
CF_FROM=101
CF_HIT_COUNT=50

Run:

npx tsx seed.ts

With this procedure, articles from the 101st onward are created with titles not containing the keyword. Therefore, the hit count remains at 50.

Running Measurements with 500 and 1000 Articles

Our bench.ts reads CF_LIMIT and CF_PAGE_SIZE, with A retrieving pages to reach a total of CF_LIMIT articles. B returns only 50 articles due to fields.title[match], so increasing CF_LIMIT doesn't affect the return count.

First, measure with 500 articles:

CF_LIMIT=500
CF_PAGE_SIZE=100
npx tsx bench.ts
# Results (ms)
- A: Retrieve all and filter on application side
  - median: 146 ms
  - runs: 2126, 125, 122, 158, 146 ms
  - hits: 50
- B: Filter on headless CMS side + select
  - median: 10 ms
  - runs: 240, 10, 9, 11, 10 ms
  - hits: 50

Next, measure with 1000 articles:

CF_LIMIT=1000
CF_PAGE_SIZE=100
npx tsx bench.ts

The results were:

# Results (ms)
- A: Retrieve all and filter on application side
  - median: 225 ms
  - runs: 2460, 242, 225, 224, 215 ms
  - hits: 50
- B: Filter on headless CMS side + select
  - median: 10 ms
  - runs: 555, 12, 10, 8, 9 ms
  - hits: 50

Additional Experiment Results

Here are the results in a table and graph:

Article Count n A: Retrieve all and filter on application side (ms, median) B: Filter on headless CMS side + select (ms, median)
100 28 10
500 146 10
1000 225 10

graph

As the article count increased from 100 to 500 to 1000, the median time for A increased from 28 ms to 146 ms to 225 ms. This means that under these experimental conditions, the processing time for A increased as the article count n increased. Note that with A, we set CF_PAGE_SIZE=100, so retrieving 500 articles requires 5 HTTP requests, and retrieving 1000 articles requires 10 HTTP requests.

On the other hand, for B, even as the article count increased to 100, 500, and 1000, the median time remained around 10 ms (10 ms, 10 ms, 10 ms). This means that under these experimental conditions, the processing time for B hardly changed even as the article count n increased.

Discussion: Why Delegating to the Data Holder Tends to Be Faster

Possible Reasons for the Difference in Scaling

Several factors may explain why A's processing time increased:

First, A's data retrieval volume increases as article count n increases. Furthermore, since our implementation paginates in units of 100, more HTTP requests are needed as n increases. Both increased data volume and request count can contribute to longer processing times.

On the other hand, B has a fixed hit count k of 50 articles, and select limits the returned fields to just title and slug. Under these conditions, the JSON size the application receives doesn't increase much as n increases, which may explain why processing time didn't increase significantly.

Correlation with Order Notation Explanation

These experimental results are consistent with the O(n) and O(k) explanation given in the background.

However, what we measured was the time to completion, which includes constant factors for networking and connection establishment. Therefore, this article doesn't claim strict proof of O(n) or O(k) complexity. What we can say is that, at least under our experimental conditions, A's time increased as n increased, while B's time hardly increased at all as n increased.

Conclusion

One reason headless CMS is said to be fast is that it can delegate search and filtering processes to the data holder. In our verification, the method of retrieving all entries and filtering on the application side took longer as the article count n increased. On the other hand, the method of filtering on the headless CMS side and limiting returned fields with select showed almost no increase in processing time even as article count increased. When adding search and filtering to article listings, it's safer to first consider API-side filtering and field selection.

Share this article

FacebookHatena blogX

Related articles