You wrote a function that loops 4,000 contacts and calls zoho.crm.updateRecord each time. It worked in dev. In prod it hits the API credit ceiling at record 2,300, throws a half-message, and leaves your data half-updated. Nobody knows because Deluge errors don’t always page anyone.
Rate limits in Zoho are three different walls. You need to know which one you hit before you can fix it.
The three walls
- Org-level API credits: a daily pool shared by every integration, custom function, and external call. Burns fast if you have webhooks.
- Statement count per function: hard cap on Deluge statements executed in one invocation. Stops you mid-loop.
- Concurrent function executions: how many functions can run in parallel. Hit it and new triggers queue, then drop.
Most “Deluge is slow today” tickets are actually concurrency queueing. Most “the script stopped halfway” tickets are statement count. Most “integrations went dark this morning” tickets are API credits.
Pattern 1: batch instead of loop
If you can use getRecords with pagination plus updateRecord with a list, do that. One bulk call is 1 credit. A thousand single calls is 1,000 credits.
// Bad: 4,000 single updates
contacts = zoho.crm.searchRecords("Contacts", "(Status:equals:Active)");
for each c in contacts
{
zoho.crm.updateRecord("Contacts", c.get("id"), {"Last_Touch": today});
}
// Good: bulk update in chunks of 100
contacts = zoho.crm.searchRecords("Contacts", "(Status:equals:Active)");
chunk = List();
for each c in contacts
{
chunk.add({"id": c.get("id"), "Last_Touch": today});
if(chunk.size() == 100)
{
zoho.crm.bulkUpdate("Contacts", chunk);
chunk.clear();
}
}
if(chunk.size() > 0)
{
zoho.crm.bulkUpdate("Contacts", chunk);
}
The bulk endpoint accepts up to 100 records per call. Use exactly 100. Don’t be clever.
Pattern 2: defer with scheduled functions
If a single invocation can’t finish, don’t try to make it. Split the work. Schedule continuation.
A trigger drops a job row into a custom module. A scheduled function runs every 5 minutes, picks up the oldest 200 jobs, processes them, marks done. Resumes next tick. Survives any cap because no single execution is huge.
// The trigger writes a job
job = Map();
job.put("Job_Type", "refresh_score");
job.put("Target_Module", "Contacts");
job.put("Target_Id", contact_id);
job.put("Status", "pending");
zoho.crm.createRecord("Job_Queue", job);
// Scheduled function (every 5 min) drains the queue
pending = zoho.crm.searchRecords("Job_Queue", "(Status:equals:pending)", 1, 200);
for each job in pending
{
try
{
// do work
zoho.crm.updateRecord("Job_Queue", job.get("id"), {"Status": "done"});
}
catch(e)
{
zoho.crm.updateRecord("Job_Queue", job.get("id"), {
"Status": "failed",
"Error": e.toString()
});
}
}
This is boring. Boring scales.
Pattern 3: backoff on 429
External calls fail with 429 when the target throttles. Don’t retry immediately. Sleep. Then retry. Exponential.
attempt = 0;
max_attempts = 5;
success = false;
while(attempt < max_attempts && !success)
{
response = invokeurl
[
url: "https://api.partner.com/sync"
type: POST
parameters: payload.toString()
headers: {"Authorization": "Bearer " + token}
];
status = response.get("status_code");
if(status == 200)
{
success = true;
}
else if(status == 429)
{
wait_ms = math.pow(2, attempt) * 1000;
thread.sleep(wait_ms.toLong());
attempt = attempt + 1;
}
else
{
break;
}
}
Cap retries. Five is plenty. Beyond that you’re hiding a real problem.
Pattern 4: short-circuit on cap
Before you start a batch, ask Zoho how many credits you’ve burned today. If you’re over 80%, defer the non-critical work.
limits = zoho.crm.getOrgVariable("api_credits_used_today");
threshold = 80000;
if(limits.toLong() > threshold)
{
// queue for tomorrow, don't run today
zoho.crm.createRecord("Job_Queue", {
"Job_Type": "nightly_refresh",
"Status": "deferred",
"Run_After": addDay(zoho.currentdate, 1)
});
return;
}
You can’t getOrgVariable that exact key out of the box, but you can maintain your own counter incrementing on every bulk call. Same effect.
Pattern 5: idempotent design
Every function should be safe to run twice. If the network drops mid-call, you’ll retry. If a colleague reruns by hand, you’ll retry. Build for it.
- Always check before update:
if(record.get("Last_Touch") < today)before writing. - Use external IDs so re-creates upsert instead of duplicating.
- Mark job rows done atomically with the work, not after.
This pattern saves you when, not if, a credit limit kicks mid-batch.
What to monitor
- Daily credit burn — alert at 70%, page at 90%.
- Failed function executions — should be zero.
- Job queue depth — should drain. If it grows linearly, you have a producer-consumer mismatch.
- Concurrent executions — look at the Functions log, count parallel green dots.
For the foundational scripting patterns, see the Zoho Deluge scripting guide. For structured error catching that pairs with this, see Deluge error handling patterns.
A real failure I keep seeing
A workflow triggers on Contact create. It calls a function that enriches via webhook to Clearbit. The webhook is slow. Bulk import lands 5,000 contacts. 5,000 functions fire in parallel. Concurrency cap hits. Half queue. Half drop. Nobody notices for two days.
The fix isn’t faster code. It’s a flag field. Workflow sets Needs_Enrichment = true. A scheduled function pulls 200 every 5 minutes. Done. Boring. Survives.
Key takeaways
Three rate-limit walls: org credits, statement count, concurrency. Don’t loop singletons when bulk exists. Defer big work to scheduled drains. Backoff on 429. Short-circuit before you hit the wall. Make every function idempotent so retries don’t corrupt. The cheapest function is the one you didn’t run.