Practical patterns for keeping Volunteer integrations fast and rate-limit-friendly — caching strategies, pagination handling, avoiding N+1, parallelization, and the cost trade-offs that matter in production.
The Volunteer API is read-heavy and pagination-bound (15 records per page, partner cannot configure). For partner integrations of any meaningful scale, performance is determined by how cleverly you avoid unnecessary requests — not by how fast each request is. A well-cached integration can serve 100,000 users on the same rate budget that a naive integration uses for 5,000.This page covers the patterns that make Volunteer integrations performant in production: caching, request consolidation, parallelization, and the specific Volunteer quirks (small page size, no conditional requests, embedded-data endpoints) that shape the right approach.
Page size on GET /users (and most list endpoints) is 15 records, not partner-configurable. Concretely:
Customer size
List requests for full read
1,000 users
~67 requests
10,000 users
~667 requests
100,000 users
~6,667 requests
The rate budget is not unlimited. Naive integrations that re-read all users every poll cycle quickly exhaust their budget for nothing — most of those records didn’t change.The single most valuable performance practice: use updated_after filters religiously. A poll that returns 50 actually-changed users beats a poll that returns 10,000 unchanged ones.
Cache keys must include the customer ID. Two customers may have Forms with the same ID (different VOMO orgs); cross-customer cache hits would produce wrong data.
On observed change (e.g., when polling detects the resource was modified)
For Forms, observed-change invalidation looks like:
JavaScript
async function pollForms(customerId) { const lastSync = await getCheckpoint(customerId, 'forms'); const updated = await fetchUpdatedForms(customerId, lastSync); for (const form of updated) { // Invalidate the cache for this Form formCache.invalidate(`${customerId}:form:${form.id}`); }}
The next access to that Form refetches and re-populates the cache.
GET /projects/{id} returns the Project with all_dates[] and next_date already populated. Don’t separately fetch Project Dates for schedule data — they’re already there.
JavaScript
// ❌ Anti-pattern: separate Project Date fetchesconst project = await getProject(projectId);const dates = await Promise.all( project.all_date_ids.map((id) => getProjectDate(id)) // wasteful — already embedded);// ✅ Use the embedded dataconst project = await getProject(projectId);const dates = project.all_dates; // already there
This eliminates N+1 patterns for common schedule reads.
The embedded data is summary-shape, not full-resource-shape. For example:
participations on User Detail include the basics but not the Project name
participants on Project Date Detail include the User basics but not the User’s full profile
For workflows that need the other resource’s full detail, you still need separate fetches. But for the common cases (showing schedule on a Project, showing participants on a Date), embedded data is enough.
The concurrency parameter caps simultaneous in-flight requests. For Volunteer, 3-5 is a reasonable upper bound — higher rates the risk of triggering rate limits.
Pattern 4: shorten polling cycles with updated_after
Every list endpoint that supports updated_after should use it. Without:
GET /users → 667 pages for a 10k-user customer
With:
GET /users?updated_after=2025-04-19T03:00:00Z → maybe 3-5 pages of actual changes
The rate budget saved is the difference between “full re-read” (~660 requests) and “what changed” (~3-5 requests). For partner integrations serving many customers, this is the difference between a sustainable cost model and rate-limit collisions.
The first poll for a new customer doesn’t have a checkpoint — it must read everything. Schedule the initial backfill explicitly (during onboarding) and avoid blocking the polling worker on it:
JavaScript
async function pollIfBackfilled(customerId) { const config = await getSyncConfig(customerId); if (!config.backfillCompletedAt) { // Backfill hasn't run yet — don't poll return { skipped: true, reason: 'awaiting_initial_backfill' }; } return pollUserChanges(customerId);}
This prevents the polling worker from doing a full read accidentally if the checkpoint is at epoch.
Use the canonical tables directly with proper indexes
Don’t refresh too frequently — refreshing a 100k-row materialized view every minute is more expensive than the underlying queries it’s accelerating.See Report on Volunteer Hours for the full pattern.
const params = new URLSearchParams({ updated_after: lastSync.toISOString() });const changedUsers = await paginate(`/users?${params}`); // typically << 10kfor (const user of changedUsers) { // Now the detail fetches are bounded by actual changes const detail = await getUserDetail(user.id); await processUser(detail);}
The polling pattern is itself an answer to N+1 — process only what changed.
For partner integrations with high-frequency reads (portals, dashboards), cold caches at startup cause spike-in-traffic patterns. Warm them:
JavaScript
async function warmCachesForCustomer(customerId) { // Pre-fetch slowly-changing data await Promise.all([ cacheOrganizations(customerId), cacheCertificates(customerId), cacheActiveForms(customerId), // Don't try to pre-warm Users or Projects — too many ]);}// On worker startupawait warmCachesForAllActiveCustomers();
The warming happens once per startup; subsequent requests hit the cache.
Before kicking off a large operation, estimate its cost:
JavaScript
async function estimateBackfillCost(customerId) { // 1. Get total count from first page's meta const response = await fetch( 'https://api.vomo.org/v1/users?page=1', { headers: { Authorization: `Bearer ${token}` } } ); const firstPage = await response.json(); const totalUsers = firstPage.meta.total; // 2. Compute pages needed const pageSize = 15; const totalPages = Math.ceil(totalUsers / pageSize); // 3. Compute time at given rate const requestsPerSecond = 3; const seconds = totalPages / requestsPerSecond; return { totalUsers, totalPages, estimatedMinutes: Math.round(seconds / 60), estimatedHours: Math.round(seconds / 3600 * 10) / 10, };}// Usage during onboardingconst estimate = await estimateBackfillCost(customerId);console.log(`Backfill estimate: ${estimate.totalUsers} users, ~${estimate.estimatedMinutes} min`);
Surface the estimate to the customer during onboarding (“Initial sync will take approximately 30 minutes”). Avoid scheduling backfills that exceed the API’s rate limit or your worker’s runtime.
A monthly report that shows “Customer X consumed 850K requests last month, of which 95% were User polling, 60% returned zero changes” tells you exactly where to optimize.
Customers often request “real-time” sync. Most of the time:
“I want to see new volunteers as soon as they sign up” → 15-minute polling is fine
“I need participation data immediately after a shift” → reconciliation within an hour is fine
“Dashboards should reflect current state” → 5-minute cache TTL is fine
The cost of “real-time” sync (in API requests, infrastructure, complexity) is rarely worth the actual freshness improvement. Push back on this requirement:
“We can poll every 15 minutes, which means new volunteers appear in your dashboard within 15 minutes of signing up. We can poll every 5 minutes, which makes that ~5 minutes — but uses 3x the API requests. Is the latency difference worth the cost?”
Most customers, when forced to articulate, accept 15-30 minute polling. The few who genuinely need sub-minute reactivity often have other architectural needs (real-time UI, websockets, etc.) that polling can’t satisfy regardless.