BambooHR Performance Tuning
Overview
Optimize BambooHR API performance through request reduction, caching, incremental sync, and connection pooling. The biggest wins come from eliminating N+1 query patterns using custom reports and the changed-since endpoint.
Prerequisites
- BambooHR API client configured
- Redis or in-memory cache available (optional)
- Performance monitoring in place
Instructions
Step 1: Eliminate N+1 Queries with Custom Reports
The single biggest performance improvement: use POST /reports/custom instead of individual employee GETs.
const dir = await client.getDirectory();
for (const emp of dir.employees) {
await client.getEmployee(emp.id, ['salary', 'hireDate']);
}
const report = await client.customReport([
'firstName', 'lastName', 'department', 'jobTitle',
'hireDate', 'workEmail', 'status', 'location',
'supervisor', 'employeeNumber',
]);
Performance impact: 500x reduction in API calls. Custom reports return all active employees in one request.
Step 2: Incremental Sync with Changed-Since
import { readFileSync, writeFileSync } from 'fs';
const LAST_SYNC_FILE = '.bamboohr-last-sync';
async function incrementalSync(client: BambooHRClient): Promise<string[]> {
let lastSync: string;
try {
lastSync = readFileSync(LAST_SYNC_FILE, 'utf-8').trim();
} catch {
lastSync = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
const changed = await client.request<{
employees: Record<string, { id: string; lastChanged: string }>;
}>('GET', `/employees/changed/?since=${lastSync}`);
const changedIds = Object.keys(changed.employees || {});
console.log(`${changedIds.length} employees changed since ${lastSync}`);
if (changedIds.length === 0) return [];
if (changedIds.length > 20) {
const report = await client.customReport([
'firstName', 'lastName', 'department', 'status',
]);
const changedData = report.employees.filter(e =>
changedIds.includes(e.id?.toString()),
);
} else {
for (const id of changedIds) {
const emp = await client.getEmployee(id, ['firstName', 'lastName', 'department', 'status']);
}
}
writeFileSync(LAST_SYNC_FILE, new Date().toISOString());
return changedIds;
}
Also available for table data:
const changedJobs = await client.request<any>(
'GET', `/employees/changed/tables/jobInfo?since=${lastSync}`,
);
Step 3: Response Caching
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, any>({
max: 500,
ttl: 5 * 60 * 1000,
});
async function cachedRequest<T>(
key: string,
fetcher: () => Promise<T>,
ttlMs?: number,
): Promise<T> {
const cached = cache.get(key) as T | undefined;
if (cached) {
console.log(`Cache hit: ${key}`);
return cached;
}
const result = await fetcher();
cache.set(key, result, { ttl: ttlMs });
return result;
}
const directory = await cachedRequest(
'directory',
() => client.getDirectory(),
5 * * ,
);
employee = (
,
client.(id, fields),
* ,
);
Redis caching for multi-instance deployments:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function redisCached<T>(
key: string,
fetcher: () => Promise<T>,
ttlSec = 300,
): Promise<T> {
const cached = await redis.get(`bamboohr:${key}`);
if (cached) return JSON.parse(cached);
const result = await fetcher();
await redis.setex(`bamboohr:${key}`, ttlSec, JSON.stringify(result));
return result;
}
async function invalidateCache(employeeId: string) {
await redis.del(`bamboohr:employee:${employeeId}`);
await redis.del('bamboohr:directory');
}
Step 4: Connection Pooling
import { Agent } from 'https';
const keepAliveAgent = new Agent({
keepAlive: true,
maxSockets: 5,
maxFreeSockets: 2,
timeout: 30_000,
keepAliveMsecs: 10_000,
});
Step 5: Request Batching with DataLoader
import DataLoader from 'dataloader';
const employeeLoader = new DataLoader<string, Record<string, string>>(
async (ids) => {
const report = await client.customReport([
'id', 'firstName', 'lastName', 'department', 'jobTitle',
]);
const byId = new Map(report.employees.map(e => [e.id, e]));
return ids.map(id => byId.get(id) || new Error(`Employee ${id} not found`));
},
{
maxBatchSize: 100,
batchScheduleFn: cb => setTimeout(cb, 50),
cache: true,
},
);
const [emp1, emp2, emp3] = .([
employeeLoader.(),
employeeLoader.(),
employeeLoader.(),
]);
Step 6: Performance Monitoring
class BambooHRMetrics {
private requests: { duration: number; status: number; endpoint: string }[] = [];
record(endpoint: string, status: number, durationMs: number) {
this.requests.push({ duration: durationMs, status, endpoint });
if (this.requests.length > 1000) this.requests.shift();
}
summary() {
const durations = this.requests.map(r => r.duration).sort((a, b) => a - b);
const errors = this.requests.filter(r => r.status >= 400);
return {
totalRequests: this..,
: (errors. / .(.., ) * ).() + ,
: durations[.(durations. * )] || ,
: durations[.(durations. * )] || ,
: durations[.(durations. * )] || ,
: .(),
};
}
() {
counts = <, >();
( r .) {
counts.(r., (counts.(r.) || ) + );
}
[...counts.()].( b[] - a[]).(, );
}
}
Output
- N+1 queries eliminated via custom reports (500x reduction)
- Incremental sync using changed-since endpoint
- Multi-tier caching (LRU in-memory + Redis)
- Connection pooling with keep-alive
- DataLoader-based request batching
- Performance metrics with p50/p95/p99
Performance Reference
| Optimization | Before | After | Improvement |
|---|
| Custom reports vs N+1 | 501 calls | 1 call | 500x |
| Incremental sync | Full pull | Delta only | 10-100x |
| Directory caching (5 min) | Every request | 1/5 min | 50x |
| Connection pooling | New conn/request | Reused | 2-3x latency |
Error Handling
| Issue | Cause | Solution |
|---|
| Cache stampede | All caches expire simultaneously | Stagger TTLs with jitter |
| Stale data | Cache TTL too long | Invalidate on webhook events |
| DataLoader timeout | Custom report too slow | Reduce batch size |
| Memory pressure | LRU cache too large | Set max entries limit |
Resources
Next Steps
For cost optimization, see bamboohr-cost-tuning.