The two constraints that matter
Two constraints shape almost every performance decision:| Constraint | What it implies |
|---|---|
| The 5,000-requests-per-hour rate limit per Virtuous organization. | Every API call counts. Sustained throughput above this rate is impossible without engineering escalation. Design with it as a hard ceiling. |
| The single-threaded write path for Transactions. | Contact Transactions and Gift Transactions are processed by the nightly batch — submission is fast, but resolution is asynchronous. Don’t wait for resolution synchronously. |
Prefer webhooks over polling
The single highest-leverage performance decision: use webhooks for change detection wherever Virtuous publishes events for the change.| Polling cost | Webhook cost |
|---|---|
| Every poll consumes a request from your rate-limit budget — even when nothing has changed. | Webhook deliveries do not consume your rate-limit budget. |
| Polling frequency caps how fresh your data can be. Polling every minute = 60 requests/hour just for one resource type. | Webhooks arrive within seconds of the source event. |
| Catches only changes that happened before your last poll timestamp. | Fires for every event regardless of source (your integration, other integrations, manual UI changes). |
POST /api/Contact/Query and POST /api/Gift/Query consumes ~400 requests/day per resource — almost 8% of an hour’s budget burned on basic change detection. The webhook equivalent costs zero from your budget.
Use polling as a reconciliation backstop, not as the primary signal. See Webhooks Overview and Reconcile Failed Syncs.
Choose the right response shape
Most read endpoints have multiple response shapes — abbreviated and full. Choose deliberately.For Contact Queries
| Endpoint | Returns | When to use |
|---|---|---|
POST /api/Contact/Query | Abbreviated Contact: id, name, contactType, email, phone, address summary | List views, sync deltas, segment exports — most uses. |
POST /api/Contact/Query/FullContact | Full Contact: all ContactIndividuals, all addresses, all custom fields, all contactReferences | Only when you genuinely need the full record for every result. |
FullContact is meaningfully slower per request. For a 10,000-Contact result set, the difference can be the cost of an hour of clock time and several hundred extra requests on the rate-limit budget.
Pattern: abbreviated query + targeted full fetch
For workflows where you process the abbreviated result and only need full detail for a small subset:JavaScript
FullContact query over the entire set.
Paginate with the right strategy
The skip/take pattern works for small to medium result sets. For very large or unstable result sets, ID-cursor pagination is better.Skip/take
JavaScript
- At high
skipvalues, server-side query performance degrades. A request withskip=50000is slower thanskip=0. - If records are inserted while paginating, the offsets shift — you can re-process or miss records.
ID-cursor pagination
JavaScript
- Each query is bounded — no
skipoverhead at high offsets. - Stable under concurrent inserts: a record inserted with a new ID lands in a later page, not in a page you’ve already processed.
- Naturally resumable: persist the cursor between batches and resume from where you stopped.
Contact Id (or equivalent ID parameter) be an indexed filter on the Query endpoint.
See Query Contacts by Filters — resumable exports for the full pattern.
Maximize take for bulk operations
Every paginated read endpoint accepts a take parameter capped at 1,000. Use the cap for bulk operations.
| Pattern | Requests for 100K records |
|---|---|
take: 25 (the default for some endpoints) | 4,000 requests — ~2.7 hours at the rate limit |
take: 100 | 1,000 requests — ~40 minutes |
take: 1000 (the cap) | 100 requests — ~4 minutes |
take values are fine. For sync, exports, and reconciliation, always use 1,000.
Filter aggressively in the request, not client-side
A common partner integration anti-pattern: pulling broad result sets and filtering on the client side. Two costs:- More requests. Each unnecessary record paginated is rate-limit budget consumed.
- More data transferred. The full response payload for records you discard is wasted bandwidth.
JavaScript
Cache reference data aggressively
Some data changes rarely and is referenced often. Cache it.What’s cacheable
| Data | TTL | Why |
|---|---|---|
| QueryOptions for a resource type | 1 day | Filter parameters and operators change rarely. |
| Project list and codes | 1 day | New Projects are added occasionally but the existing set is stable. |
| Campaign list | 1 day | Same as Projects. |
| Premium list | 1 day | Configured at setup; rarely changes during normal operation. |
| RelationshipTypes | 1 week | Almost never changes. |
| GiftCustomFields, ContactCustomFields metadata | 1 day | Adding a new custom field is rare. |
What’s not cacheable
| Data | Why |
|---|---|
| Specific Contacts or Gifts | Change frequently; cached records go stale fast. |
| Query results | Result sets are filter-dependent; caching them produces stale data and complex invalidation. |
| Webhook subscription details | Could change; query when needed. |
Implementation pattern
JavaScript
Run requests concurrently, but bounded
If you have a hundred records to update, you don’t need to do them sequentially. But you also can’t fire all hundred concurrently — that bursts the rate limit and your error rate spikes. The pattern: a bounded concurrency limit, typically 4–8 concurrent in-flight requests.JavaScript
Use a token bucket for steady-state pacing
For continuous workloads (incremental sync, ongoing reconciliation), a token bucket smooths request dispatch to fit the rate limit while still allowing modest bursts:JavaScript
Reuse HTTP connections
Every TCP and TLS handshake adds latency. Reusing connections via HTTP keep-alive eliminates that cost. In Node.js with the global fetch, connection pooling is automatic for the same origin. In other runtimes:- Java/Apache HttpClient: configure a connection pool with appropriate
maxConnPerRoute. - Python/requests: use a
Session()object rather than module-levelrequests.get(). - Go: use a long-lived
http.Clientwith a configuredTransport. - Ruby/Net::HTTP: use a long-lived
Net::HTTP::Persistentinstance.
Defer heavy work in webhook handlers
A webhook handler should acknowledge the delivery as quickly as possible and defer processing. Heavy work inside the handler increases the risk of timeout-triggered retries — which produces duplicate deliveries you have to defend against.JavaScript
Batch where the API supports it
Most Virtuous endpoints accept one record per request. A few accept batches:| Endpoint | Batch |
|---|---|
POST /api/Tag/Bulk | Apply a tag to many Contacts in one request |
POST /api/ContactNote/Bulk | Create many notes in one request |
The CRM+ spec exposes only a small set of batch endpoints. If a workflow requires bulk operations that don’t have a batch endpoint, you may need to escalate to Virtuous engineering for either a per-organization rate-limit exception (see Rate Limits) or a feature request for a new batch endpoint.
Monitor the right metrics
Performance regressions are usually visible in one of these metrics before they become user-visible:| Metric | Investigate when |
|---|---|
| Average request latency | Sustained increases (server-side slowness or networking issue) |
| 95th percentile request latency | Outliers growing (some specific query type is slow) |
| Rate-limit headers on responses | X-Rate-Limit-Remaining trending toward zero |
| 429 response count | Any non-zero count is a sign the throttle is misconfigured |
| Queue depth (for async architectures) | Growing depth indicates a downstream bottleneck |
Where to go next
Error Recovery Patterns
The companion practices for handling the inevitable failures.
Rate Limits
The reference for the rate-limit budget all these patterns optimize for.
Pagination and Filtering
The mechanics of paginated reads that several patterns on this page depend on.
Build a Nightly Data Sync
A recipe that puts the throttling and pacing patterns from this page into practice.