notion-cost-tuning

Optimize Notion API usage to minimize rate-limit pressure, reduce engineering overhead, and maximize throughput. Use when auditing request volume, eliminating redundant API calls, implementing caching, or restructuring queries for efficiency. Trigger with "notion cost", "notion optimize", "notion API usage", "reduce notion requests", "notion rate limit budget", "notion efficient", "notion caching".

1,868 stars

Best use case

notion-cost-tuning is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Optimize Notion API usage to minimize rate-limit pressure, reduce engineering overhead, and maximize throughput. Use when auditing request volume, eliminating redundant API calls, implementing caching, or restructuring queries for efficiency. Trigger with "notion cost", "notion optimize", "notion API usage", "reduce notion requests", "notion rate limit budget", "notion efficient", "notion caching".

Teams using notion-cost-tuning should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/notion-cost-tuning/SKILL.md --create-dirs "https://raw.githubusercontent.com/jeremylongshore/claude-code-plugins-plus-skills/main/plugins/saas-packs/notion-pack/skills/notion-cost-tuning/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/notion-cost-tuning/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How notion-cost-tuning Compares

Feature / Agentnotion-cost-tuningStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Optimize Notion API usage to minimize rate-limit pressure, reduce engineering overhead, and maximize throughput. Use when auditing request volume, eliminating redundant API calls, implementing caching, or restructuring queries for efficiency. Trigger with "notion cost", "notion optimize", "notion API usage", "reduce notion requests", "notion rate limit budget", "notion efficient", "notion caching".

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

Related Guides

SKILL.md Source

# Notion Cost Tuning

## Overview

The Notion API is **free with every workspace plan** — there is no per-call pricing. The real "cost" is the **3 requests/second rate limit** (per integration token) and engineering time wasted on inefficient patterns. Apply six strategies below to reduce request volume by 80-95%.

**Notion workspace pricing (for context — API access is included at every tier):**

| Plan | Price | API Access | Rate Limit |
|------|-------|------------|------------|
| Free | $0 | Full API | 3 req/sec |
| Plus | $12/user/mo | Full API | 3 req/sec |
| Business | $28/user/mo | Full API | 3 req/sec |
| Enterprise | Custom | Full API | 3 req/sec |

The rate limit is identical across all plans. Optimization is about staying within 3 req/sec, not reducing a bill.

## Prerequisites

- `@notionhq/client` v2.x installed (`npm install @notionhq/client`)
- Integration token from [notion.so/my-integrations](https://www.notion.so/my-integrations)
- Token shared with target pages/databases via the **Connections** menu in Notion
- For queue patterns: `p-queue` v8+ (`npm install p-queue`)
- For caching: `node-cache` or `lru-cache` (`npm install lru-cache`)

## Instructions

### Step 1: Audit Current Request Volume

Before optimizing, measure your baseline. Instrument the Notion client to track every API call by method, endpoint, and timestamp.

```typescript
import { Client } from '@notionhq/client';

interface RequestEntry {
  method: string;
  endpoint: string;
  timestamp: number;
  durationMs: number;
}

const requestLog: RequestEntry[] = [];
const notion = new Client({ auth: process.env.NOTION_TOKEN });

// Wrap any Notion call with tracking
async function tracked<T>(
  method: string,
  endpoint: string,
  fn: () => Promise<T>,
): Promise<T> {
  const start = Date.now();
  try {
    return await fn();
  } finally {
    requestLog.push({
      method,
      endpoint,
      timestamp: start,
      durationMs: Date.now() - start,
    });
  }
}

// Generate audit report
function auditReport() {
  const last60s = requestLog.filter(r => r.timestamp > Date.now() - 60_000);
  const byMethod = Object.groupBy(last60s, r => r.method);

  console.table({
    totalAllTime: requestLog.length,
    lastMinute: last60s.length,
    reqPerSecond: (last60s.length / 60).toFixed(2),
    avgLatencyMs: (
      last60s.reduce((sum, r) => sum + r.durationMs, 0) / last60s.length
    ).toFixed(0),
  });

  // Show hotspots — which methods consume the most budget
  for (const [method, entries] of Object.entries(byMethod)) {
    console.log(`  ${method}: ${entries!.length} calls (${((entries!.length / last60s.length) * 100).toFixed(0)}%)`);
  }
}

// Example: track a database query
const results = await tracked('databases.query', `/databases/${dbId}/query`, () =>
  notion.databases.query({ database_id: dbId }),
);

// Run report every 60 seconds
setInterval(auditReport, 60_000);
```

**Target:** identify which operations consume > 50% of your request budget. Common culprits are polling loops, page retrieves that duplicate database query data, and unfiltered full-table scans.

### Step 2: Eliminate Redundant Reads and Reduce Payload Size

Three high-impact patterns to cut reads immediately:

**Pattern A: Stop retrieving pages you already have from database queries.** Database query results include all properties — a separate `pages.retrieve` is redundant unless you need blocks.

```typescript
// WASTEFUL: 2 requests per page (query + retrieve)
const { results } = await notion.databases.query({ database_id: dbId });
for (const page of results) {
  const full = await notion.pages.retrieve({ page_id: page.id }); // redundant!
  processPage(full);
}

// EFFICIENT: 1 request total — properties are already in query results
const { results } = await notion.databases.query({ database_id: dbId });
for (const page of results) {
  processPage(page); // same properties, no extra request
}
```

**Pattern B: Use `filter_properties` to reduce response size.** When you only need specific properties, pass their IDs to shrink the payload by 60-90%.

```typescript
// First, discover property IDs (one-time setup)
const db = await notion.databases.retrieve({ database_id: dbId });
console.log(
  Object.entries(db.properties).map(([name, prop]) => `${name}: ${prop.id}`),
);
// Output: ["Status: abc1", "Assignee: def2", "Due Date: ghi3", ...]

// Then query with only the properties you need
const { results } = await notion.databases.query({
  database_id: dbId,
  filter_properties: ['abc1', 'def2'], // Only Status and Assignee
});
// Response is 60-90% smaller — faster network, faster parsing
```

**Pattern C: Use `last_edited_time` to fetch only changes since last sync.**

```typescript
async function getChangesSince(dbId: string, sinceISO: string) {
  return notion.databases.query({
    database_id: dbId,
    filter: {
      timestamp: 'last_edited_time',
      last_edited_time: { after: sinceISO },
    },
    sorts: [{ timestamp: 'last_edited_time', direction: 'descending' }],
    page_size: 100,
  });
}

// Incremental sync: only fetch what changed
let lastSync = new Date().toISOString();

async function syncLoop() {
  const changes = await getChangesSince(dbId, lastSync);
  if (changes.results.length > 0) {
    console.log(`${changes.results.length} pages modified since ${lastSync}`);
    await processChanges(changes.results);
    lastSync = new Date().toISOString();
  }
}
// 1-5 requests/minute instead of re-fetching entire database each time
```

### Step 3: Cache, Batch, and Replace Polling

**Caching:** Most Notion data is read-heavy. Cache database query results and page content with TTL-based invalidation.

```typescript
import { LRUCache } from 'lru-cache';

const pageCache = new LRUCache<string, any>({
  max: 500,
  ttl: 5 * 60 * 1000, // 5-minute TTL
});

async function getCachedPage(pageId: string) {
  const cached = pageCache.get(pageId);
  if (cached) return cached; // 0 API requests

  const page = await notion.pages.retrieve({ page_id: pageId });
  pageCache.set(pageId, page);
  return page;
}

// Cache database queries by filter hash
const queryCache = new LRUCache<string, any>({
  max: 100,
  ttl: 2 * 60 * 1000, // 2-minute TTL for queries
});

async function cachedQuery(dbId: string, filter: any) {
  const key = `${dbId}:${JSON.stringify(filter)}`;
  const cached = queryCache.get(key);
  if (cached) return cached;

  const result = await notion.databases.query({
    database_id: dbId,
    filter,
    page_size: 100,
  });
  queryCache.set(key, result);
  return result;
}
```

**Batching writes:** The Notion API has no true batch endpoint, but `blocks.children.append` accepts up to 100 blocks per call.

```typescript
import PQueue from 'p-queue';

// Rate-limited queue: respects 3 req/sec
const queue = new PQueue({ concurrency: 3, interval: 1000, intervalCap: 3 });

// BAD: 100 page creates = 100 sequential requests (~34 seconds at 3/sec)
for (const item of items) {
  await notion.pages.create({
    parent: { database_id: dbId },
    properties: toProperties(item),
  });
}

// BETTER: 100 page creates via queue = 100 requests but 3x faster (~34 sec → 34 sec but concurrent)
await Promise.all(
  items.map(item =>
    queue.add(() =>
      notion.pages.create({
        parent: { database_id: dbId },
        properties: toProperties(item),
      }),
    ),
  ),
);

// BEST for content: batch blocks into single append calls (100 blocks = 1 request)
const blocks = items.map(item => ({
  type: 'paragraph' as const,
  paragraph: {
    rich_text: [{ type: 'text' as const, text: { content: item.text } }],
  },
}));

// Chunk into groups of 100 (API limit per call)
for (let i = 0; i < blocks.length; i += 100) {
  await queue.add(() =>
    notion.blocks.children.append({
      block_id: parentPageId,
      children: blocks.slice(i, i + 100),
    }),
  );
}
```

**Replace polling with webhooks:** Polling a single database every 10 seconds costs 360 requests/hour (3600s / 10s interval). Webhooks cost zero.

```typescript
import express from 'express';

// POLLING (wasteful): 360 requests/hour per database (3600s / 10s = 360)
setInterval(async () => {
  const pages = await notion.databases.query({ database_id: dbId });
  await processPages(pages.results);
}, 10_000);

// WEBHOOK (efficient): 0 polling requests, fetch only on change
const app = express();
app.post('/webhooks/notion', express.json(), async (req, res) => {
  // Acknowledge immediately (Notion expects 200 within 3 seconds)
  res.status(200).json({ ok: true });

  const { type, data } = req.body;
  if (type === 'page.properties_updated' || type === 'page.content_updated') {
    // Invalidate cache, then fetch only the changed page
    pageCache.delete(data.id);
    const page = await notion.pages.retrieve({ page_id: data.id });
    await processPage(page);
  }
});
```

## Output

After applying these optimizations:

- **Audit report** showing request volume baseline and hotspots
- **Redundant reads eliminated** — no duplicate `pages.retrieve` after `databases.query`
- **Payload sizes reduced** 60-90% via `filter_properties`
- **Incremental sync** via `last_edited_time` filter replacing full-table scans
- **Cache layer** with TTL-based invalidation for reads
- **Write throughput maximized** via queue-based throttling and block batching
- **Polling eliminated** where webhooks are available (360 req/hr per database saved)

**Typical impact:** integrations drop from 500+ requests/hour to under 200 requests/hour (80-95% reduction), staying well within the 3 req/sec limit even with multiple databases.

## Error Handling

| Issue | Cause | Solution |
|-------|-------|----------|
| 429 Too Many Requests despite optimization | Shared token across multiple services | Use separate integration tokens per service; each gets its own 3 req/sec budget |
| Stale cached data causing bugs | Cache TTL too long for the use case | Shorten TTL to 30-60s for volatile data, or use webhook-based cache invalidation |
| Webhook not triggering | Integration not connected to the page/database | Share via **Connections** menu in Notion; verify webhook URL is publicly accessible |
| `filter_properties` returning empty results | Using property names instead of IDs | Run `databases.retrieve` to get property IDs (short strings like `abc1`), not display names |
| Incremental sync missing updates | Clock skew between client and Notion server | Subtract 5-second buffer from `lastSync` timestamp to create overlap window |
| `blocks.children.append` failing with 400 | More than 100 children in single call | Chunk arrays into groups of 100 before appending |

## Examples

### Request Reduction Calculator

Estimate savings before implementing changes:

```typescript
function estimateRequestSavings(config: {
  databases: number;
  avgPagesPerDb: number;
  pollIntervalSeconds: number;
  hoursPerDay: number;
  retrievePerPage: number; // extra retrieve calls per page (0 = none)
}) {
  const pollsPerHour = 3600 / config.pollIntervalSeconds;
  const paginationCalls = Math.ceil(config.avgPagesPerDb / 100);

  // Before: polling + pagination + redundant retrieves
  const beforePerHour =
    config.databases * pollsPerHour * paginationCalls +
    config.databases * pollsPerHour * config.avgPagesPerDb * config.retrievePerPage;

  // After: webhook-driven (0 polling) + no redundant retrieves
  // Estimate ~5% of pages change per hour, triggering on-demand reads
  const changedPagesPerHour = config.databases * config.avgPagesPerDb * 0.05;
  const afterPerHour = changedPagesPerHour; // 1 request per changed page

  const dailySavings = (beforePerHour - afterPerHour) * config.hoursPerDay;

  console.log(`=== Request Budget Analysis ===`);
  console.log(`Before: ${beforePerHour.toFixed(0)} requests/hour`);
  console.log(`After:  ${afterPerHour.toFixed(0)} requests/hour`);
  console.log(`Savings: ${dailySavings.toFixed(0)} requests/day (${((1 - afterPerHour / beforePerHour) * 100).toFixed(0)}% reduction)`);
  console.log(`Rate limit headroom: ${((3 * 3600 - afterPerHour) / (3 * 3600) * 100).toFixed(0)}% of 3 req/sec budget unused`);
}

// Example: 5 databases, 200 pages each, polling every 30 seconds, 12 hours/day
estimateRequestSavings({
  databases: 5,
  avgPagesPerDb: 200,
  pollIntervalSeconds: 30,
  hoursPerDay: 12,
  retrievePerPage: 1,
});
// Before: 121,200 requests/hour
// After:  50 requests/hour
// Savings: 1,453,800 requests/day (99% reduction)
```

### Full Optimization Wrapper

Drop-in wrapper that adds caching, tracking, and queue management to the Notion client:

```typescript
import { Client } from '@notionhq/client';
import { LRUCache } from 'lru-cache';
import PQueue from 'p-queue';

export function createOptimizedClient(token: string) {
  const notion = new Client({ auth: token });
  const cache = new LRUCache<string, any>({ max: 1000, ttl: 5 * 60 * 1000 });
  const writeQueue = new PQueue({ concurrency: 3, interval: 1000, intervalCap: 3 });
  let requestCount = 0;

  return {
    // Cached page retrieve
    async getPage(pageId: string) {
      const cached = cache.get(`page:${pageId}`);
      if (cached) return cached;
      requestCount++;
      const page = await notion.pages.retrieve({ page_id: pageId });
      cache.set(`page:${pageId}`, page);
      return page;
    },

    // Cached + filtered database query
    async queryDatabase(dbId: string, filter?: any, filterProps?: string[]) {
      const key = `query:${dbId}:${JSON.stringify(filter)}:${filterProps?.join(',')}`;
      const cached = cache.get(key);
      if (cached) return cached;
      requestCount++;
      const result = await notion.databases.query({
        database_id: dbId,
        ...(filter && { filter }),
        ...(filterProps && { filter_properties: filterProps }),
        page_size: 100,
      });
      cache.set(key, result);
      return result;
    },

    // Queued page create (respects rate limit)
    async createPage(dbId: string, properties: any) {
      return writeQueue.add(() => {
        requestCount++;
        return notion.pages.create({
          parent: { database_id: dbId },
          properties,
        });
      });
    },

    // Invalidate cache for a specific page or database
    invalidate(id: string) {
      for (const key of cache.keys()) {
        if (key.includes(id)) cache.delete(key);
      }
    },

    // Stats
    get stats() {
      return { requestCount, cacheSize: cache.size, queuePending: writeQueue.pending };
    },
  };
}
```

## Resources

- [Notion API Rate Limits](https://developers.notion.com/reference/request-limits) — 3 req/sec per token, `Retry-After` header on 429
- [Database Query Filter](https://developers.notion.com/reference/post-database-query-filter) — push filtering server-side
- [Filter Properties Parameter](https://developers.notion.com/reference/retrieve-a-page#filter-properties) — reduce response payload size
- [Notion Webhooks](https://developers.notion.com/reference/webhooks) — event-driven updates replacing polling
- [Block Children Append](https://developers.notion.com/reference/patch-block-children) — batch up to 100 blocks per call
- [Notion Pricing](https://www.notion.so/pricing) — API included at all tiers, no per-call charges

## Next Steps

For rate-limit retry patterns, see `notion-rate-limits`. For query and search patterns, see `notion-search-retrieve`. For overall architecture guidance, see `notion-reference-architecture`.

Related Skills

workhuman-performance-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Workhuman performance tuning for employee recognition and rewards API. Use when integrating Workhuman Social Recognition, or building recognition workflows with HRIS systems. Trigger: "workhuman performance tuning".

workhuman-cost-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Workhuman cost tuning for employee recognition and rewards API. Use when integrating Workhuman Social Recognition, or building recognition workflows with HRIS systems. Trigger: "workhuman cost tuning".

wispr-performance-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Wispr Flow performance tuning for voice-to-text API integration. Use when integrating Wispr Flow dictation, WebSocket streaming, or building voice-powered applications. Trigger: "wispr performance tuning".

wispr-cost-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Wispr Flow cost tuning for voice-to-text API integration. Use when integrating Wispr Flow dictation, WebSocket streaming, or building voice-powered applications. Trigger: "wispr cost tuning".

windsurf-performance-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Optimize Windsurf IDE performance: indexing speed, Cascade responsiveness, and memory usage. Use when Windsurf is slow, indexing takes too long, Cascade times out, or the IDE uses too much memory. Trigger with phrases like "windsurf slow", "windsurf performance", "optimize windsurf", "windsurf memory", "cascade slow", "indexing slow".

windsurf-cost-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Optimize Windsurf licensing costs through seat management, tier selection, and credit monitoring. Use when analyzing Windsurf billing, reducing per-seat costs, or implementing usage monitoring and budget controls. Trigger with phrases like "windsurf cost", "windsurf billing", "reduce windsurf costs", "windsurf pricing", "windsurf budget".

webflow-performance-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Optimize Webflow API performance with response caching, bulk endpoint batching, CDN-cached live item reads, pagination optimization, and connection pooling. Use when experiencing slow API responses or optimizing request throughput. Trigger with phrases like "webflow performance", "optimize webflow", "webflow latency", "webflow caching", "webflow slow", "webflow batch".

webflow-cost-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Optimize Webflow costs through plan selection, CDN read optimization, bulk endpoint usage, and API usage monitoring with budget alerts. Use when analyzing Webflow billing, reducing API costs, or implementing usage monitoring for Webflow integrations. Trigger with phrases like "webflow cost", "webflow billing", "reduce webflow costs", "webflow pricing", "webflow budget".

vercel-performance-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Optimize Vercel deployment performance with caching, bundle optimization, and cold start reduction. Use when experiencing slow page loads, optimizing Core Web Vitals, or reducing serverless function cold start times. Trigger with phrases like "vercel performance", "optimize vercel", "vercel latency", "vercel caching", "vercel slow", "vercel cold start".

vercel-cost-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Optimize Vercel costs through plan selection, function efficiency, and usage monitoring. Use when analyzing Vercel billing, reducing function execution costs, or implementing spend management and budget alerts. Trigger with phrases like "vercel cost", "vercel billing", "reduce vercel costs", "vercel pricing", "vercel expensive", "vercel budget".

veeva-performance-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Veeva Vault performance tuning for REST API and clinical operations. Use when working with Veeva Vault document management and CRM. Trigger: "veeva performance tuning".

veeva-cost-tuning

1868
from jeremylongshore/claude-code-plugins-plus-skills

Veeva Vault cost tuning for REST API and clinical operations. Use when working with Veeva Vault document management and CRM. Trigger: "veeva cost tuning".