Claude-code-plugins notion-cost-tuning
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/notion-pack/skills/notion-cost-tuning" ~/.claude/skills/jeremylongshore-claude-code-plugins-notion-cost-tuning && rm -rf "$T"
plugins/saas-packs/notion-pack/skills/notion-cost-tuning/SKILL.mdNotion 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
v2.x installed (@notionhq/client
)npm install @notionhq/client- Integration token from notion.so/my-integrations
- Token shared with target pages/databases via the Connections menu in Notion
- For queue patterns:
v8+ (p-queue
)npm install p-queue - For caching:
ornode-cache
(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.
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.
// 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
to reduce response size. When you only need specific properties, pass their IDs to shrink the payload by 60-90%.filter_properties
// 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
to fetch only changes since last sync.last_edited_time
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.
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.
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.
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
afterpages.retrievedatabases.query - Payload sizes reduced 60-90% via
filter_properties - Incremental sync via
filter replacing full-table scanslast_edited_time - 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 |
returning empty results | Using property names instead of IDs | Run to get property IDs (short strings like ), not display names |
| Incremental sync missing updates | Clock skew between client and Notion server | Subtract 5-second buffer from timestamp to create overlap window |
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:
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:
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 — 3 req/sec per token,
header on 429Retry-After - Database Query Filter — push filtering server-side
- Filter Properties Parameter — reduce response payload size
- Notion Webhooks — event-driven updates replacing polling
- Block Children Append — batch up to 100 blocks per call
- Notion 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.