The “Task timed out after 20.02 seconds” error means your HubSpot custom code action ran past its hard 20-second wall-clock limit — and you cannot raise it. The fix is not a faster call inside the action; it’s an architectural change. Do fast validation in the action, POST the slow work to your own worker (queue or endpoint) keyed on the enrolled object’s id, and return immediately within budget. The worker does the heavy lifting and writes the result back via the HubSpot CRM API. As of 2026-06-22, HubSpot documents the 20-second execution ceiling and a 128 MB memory ceiling as fixed, non-configurable limits.
Our senior engineering team designs and ships the offload-and-callback pattern below as production HubSpot integrations, with idempotency and rate-limit handling baked in.
Talk to our HubSpot team →
The war story: the sync that only broke at scale
A custom code action that had run clean for months started failing — but only on a fraction of records, and only at the busiest times. The workflow was simple on paper: a contact enters, the action calls a third-party enrichment API, maps a few fields, and writes them back. In testing it returned in two or three seconds. In a quiet office, fine.
Then a large list import enrolled several thousand contacts at once. The enrichment vendor’s API, under its own load, started answering in eight, twelve, fifteen seconds instead of two. The action’s logs filled with one line: Task timed out after 20.02 seconds. The records that failed were precisely the ones that needed enriching — the new, sparse contacts whose lookups were slowest. The “fix” everyone reaches for first — wrap the call in a retry, add a timeout flag, ask HubSpot support to bump the limit — does not exist. There is no setting to change.
That last line is the important one, so let’s be exact about why.
Why does a HubSpot custom code action time out at 20 seconds?
Custom code actions run as serverless functions on AWS Lambda that HubSpot manages for you. That’s the whole explanation for the ceiling: you don’t own the runtime, so you don’t get to resize it. As of 2026-06-22, HubSpot’s custom code actions documentation states the action must finish running within 20 seconds and can use up to 128 MB of memory. Both are system-wide and not configurable.
The 20-second clock is wall-clock time, and that is the trap. It counts everything — including time spent awaiting an external HTTP response. Your code can be flawless and still time out because a third party was slow. The two reliable ways to blow the budget:
- Slow synchronous external calls. One enrichment, geocode, or payment-provider call that normally takes 2s but spikes to 18s under load. Chain two of those and you’re done.
- Large per-record loops. Iterating an association set, paginating a search, or looping a few hundred line items inside the action. Each iteration is cheap; the sum isn’t.
About that .02: the documented limit is exactly 20 seconds. The 20.02 in the error string is Lambda overhead on top of the ceiling — it is the error text, not a hidden two-hundredths of slack you can plan around. Treat your real budget as well under 20 seconds.
The other ceiling people forget
Memory (128 MB) is the quieter limit. Pulling a large search result fully into an array, or building a big in-memory structure across a loop, can exhaust it before the clock does. The time limit is the more frequently hit and more prominently documented of the two, but design for both.
How do I find what is eating the 20 seconds?
Before you restructure anything, measure. The mistake is guessing which call is slow and optimizing the wrong one. Wrap every outbound call and every loop in a coarse timer and log it — console.log output surfaces in the workflow’s execution history, so you can read it per record.
// Diagnostic wrapper: log how long each external call actually takes.
// Pre-installed in the runtime, so no bundling needed: @hubspot/api-client ^10, axios ^1.2.0.
const axios = require('axios');
async function timed(label, fn) {
const start = Date.now();
try {
return await fn();
} finally {
// Visible in the workflow execution history for this enrolled record.
console.log(`${label} took ${Date.now() - start}ms`);
}
}
// Usage inside exports.main:
const data = await timed('enrichment-api', () =>
axios.get('https://slow-vendor.example.com/enrich', { timeout: 8000 })
);

This pattern follows HubSpot’s documented behavior; validate it against your own portal before shipping to production. If a single call routinely takes 8–15 seconds, no amount of micro-optimization saves you — that work does not belong inline. Which brings us to the only durable fix.
You can’t raise the limit — so move the work
The pattern that actually holds at scale: the action becomes a dispatcher, not a worker. It validates the input fast, hands the slow job to something you control, and returns inside budget. The result lands back on the record moments later, asynchronously.
Concretely, the action should:
- Read the enrolled object id and the few inputs it needs from the event payload.
- Do cheap, synchronous validation only (is the email present, is the domain plausible).
- POST the job to your own endpoint or queue — with an idempotency key equal to the enrolled object id — and not wait for the heavy work to finish.
- Return
outputFieldsimmediately so the workflow continues.
Note that HubSpot pre-installs the libraries you need here — as of 2026-06-22 the docs list @hubspot/api-client ^10, axios ^1.2.0, and lodash ^4.17.20 among others — so you generally don’t bundle them yourself.
// Custom code action: validate fast, enqueue the slow work, return within budget.
// Runs on HubSpot's managed Lambda — keep everything here well under 20s.
const axios = require('axios');
exports.main = async (event, callback) => {
// 1. The enrolled object's id is your stable idempotency key.
// For a contact-based workflow this is the contact id.
const objectId = event.object.objectId;
const { email, domain } = event.inputFields; // mapped in the action UI
// 2. Fast, synchronous validation only. No external calls here.
if (!email || !email.includes('@')) {
return callback({
outputFields: { enqueued: 'false', reason: 'missing_or_invalid_email' },
});
}
// 3. Enqueue the heavy job to a service you control. The idempotency key
// is the enrolled objectId (+ a stable operation name) so HubSpot's
// multi-day retries can't double-apply the downstream work.
const idempotencyKey = `enrich:${objectId}`;
try {
// Small randomized jitter so a burst enrollment (e.g. a 10k-row import)
// doesn't align every request into the same millisecond and self-DDoS.
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 250)));
await axios.post(
'https://worker.your-domain.com/jobs/enrich',
{ objectId, email, domain },
{
// A short client timeout: the enqueue must be fast. We are NOT
// waiting for enrichment, only for the job to be accepted.
timeout: 4000,
headers: {
'Idempotency-Key': idempotencyKey,
'Authorization': `Bearer ${process.env.WORKER_TOKEN}`, // from action secrets
},
}
);
} catch (err) {
// 4. Throw on 429 / 5XX so HubSpot retries the WHOLE action later.
// Swallowing the error means NO retry — see the retry section below.
const status = err.response && err.response.status;
if (status === 429 || (status >= 500 && status < 600)) {
throw err; // HubSpot will re-enqueue this step (idempotent by design)
}
// Non-retryable (e.g. 400 from our own worker): record and move on.
return callback({
outputFields: { enqueued: 'false', reason: `worker_${status || 'error'}` },
});
}
// 5. Return immediately. The result will be patched onto the record later.
return callback({
outputFields: { enqueued: 'true', idempotencyKey },
});
};
The action’s job is done in well under a second of its own compute. The expensive vendor call now lives in your worker, where you own the timeout, the concurrency, and the retry policy.
The worker: do the slow work, patch the record back
The worker accepts the job, ACKs fast, performs the enrichment at its own pace, and writes the result back to HubSpot via the CRM API. The control flow matters: mark the idempotency key “seen” only after the CRM write succeeds, and wrap the slow work in a try/catch that does not mark it seen on failure — so a later HubSpot retry can cleanly re-run. The in-memory store below is a sketch; an in-memory Map is not a real idempotency layer across worker restarts or horizontal scaling, so replace it with Redis or a database in production.
// Sketch of the external worker (your infra). Slow work lives here.
const { Client } = require('@hubspot/api-client');
const hubspot = new Client({ accessToken: process.env.HUBSPOT_TOKEN });
const seen = new Map(); // replace with Redis/DB in production
app.post('/jobs/enrich', async (req, res) => {
const key = req.header('Idempotency-Key');
const { objectId, email, domain } = req.body;
// Dedupe: HubSpot may retry the action for up to three days, so the same
// job can arrive more than once. Return the prior result, don't re-run.
if (seen.has(key)) return res.status(200).json(seen.get(key));
res.status(202).json({ accepted: true }); // ACK fast so the action returns
// Wrap the slow path. We have ALREADY sent 202, so a throw here would be an
// unhandled rejection — catch it, and do NOT mark 'seen' so a retry re-runs.
try {
// Now take all the time we need — minutes, if necessary.
const enriched = await slowThirdPartyEnrichment(email, domain);
// Write results back to the enrolled record. The CRM update is idempotent:
// re-running it just sets the same property values again.
await hubspot.crm.contacts.basicApi.update(objectId, {
properties: {
company_size: enriched.size,
industry: enriched.industry,
enrichment_status: 'complete',
},
});
// Mark complete ONLY after the CRM write succeeds.
seen.set(key, { objectId, status: 'complete' });
} catch (e) {
console.error('enrichment failed', key, e.message);
// Do NOT mark seen — a HubSpot retry (or your own queue) can re-run cleanly.
}
});
This pattern follows HubSpot’s documented behavior; validate it against your own portal before shipping to production. If you need the result back inside the same workflow rather than just on the record, that’s the domain of custom (extension) workflow actions — a separate public API where an action can return hs_execution_state: BLOCK to pause enrollment (default up to one week) and resume via the /automation/v4/actions/callbacks/{callbackId}/complete endpoint. Be precise here: that BLOCK/callback mechanism belongs to the extension-action API, not the in-app custom code action you write in the workflow editor. Don’t go looking for a BLOCK return value in the in-app code editor — it isn’t exposed there.
Why does the timeout fire even though my own code is fast?
Because the 20-second clock is wall-clock, not CPU time. A single Lambda execution is blocked on I/O while it awaits a slow third-party HTTP response, and Lambda charges that wait against your 20 seconds even though your own logic is trivial. This is exactly why the diagnostic timer above matters and why offloading works: the slow part is almost always someone else’s latency, and the fix is to stop waiting on it inside the action.
Why idempotency is non-negotiable here
HubSpot’s retry behavior makes idempotency mandatory, not optional. As of 2026-06-22 the docs state that if a call fails with a 429 or 5XX error, HubSpot will reattempt the entire action for up to three days, starting one minute after the failure, with intervals growing to a maximum gap of eight hours. The whole action re-runs — every side effect with it.
So the same enrolled record can fire your downstream work multiple times across that window. If your worker isn’t idempotent, a partial success followed by a retry double-applies: two enrichment charges, two “welcome” emails, two rows. Keying every write on the enrolled objectId (plus a stable operation name) means a retry updates rather than duplicates. HubSpot does not give you an idempotency key for free — the object id is in the event payload, but implementing the dedupe is on you.
Throwing the error is the retry trigger
This is the subtle part. The automatic retry only happens if you throw the error in your Node.js catch block (or raise in Python’s except). Catch a 429 and swallow it, and HubSpot will not retry — the step just completes as a no-op. Conversely, deliberately throwing on a 429 is the documented way to make HubSpot re-enqueue the step later instead of you hammering the API inside a tight loop that eats your 20-second budget. Re-throwing rate-limit errors and letting the pre-installed clients back off is the same discipline you want anywhere you consume HubSpot 429s.
Respect rate limits inside the action
Picture that 10,000-record import again. If every enrollment fires its outbound call at the same instant, you’ve built an accidental denial-of-service against your own worker or the third-party API. Two cheap defenses, both in the code above:
- Jitter. A small randomized delay (a couple hundred milliseconds) before the outbound call so a burst doesn’t align. It’s trivial against a 20s budget and smooths the spike.
- Lean on built-in 429/5XX handling. The pre-installed
@hubspot/api-clientandaxiosalready back off on those errors. Don’t write tight retry loops inside the action — they burn your wall-clock budget and risk the very timeout you’re trying to avoid. Throw, and let HubSpot’s multi-day retry do the waiting.
A note on output limits
While you’re in there: action outputs are capped too. As of 2026-06-22, string-formatted outputs are limited to 65,000 characters, you can define up to 50 properties, and all secret values combined must stay under 1,000 characters. If you’re tempted to pass a large payload out of the action, that’s another signal the work belongs in your worker, not the action’s output fields.
When to drop the custom code action entirely
The offload pattern saves most cases. But if the work is genuinely long-running, stateful, needs guaranteed delivery, or involves orchestration the action can’t express, stop fighting the 20-second box. Skip the custom code action and do this instead:
- Subscribe to a HubSpot webhook for the trigger event (or use a small workflow whose only job is to call your endpoint).
- Run the entire logic in your own service, with your own timeouts, retries, queues, and observability.
- Write results back through the CRM API.
You trade a little setup for a runtime you actually control — no 20-second guillotine, no 128 MB ceiling, real logs. For anything that’s become load-bearing to the business, that trade is worth making.
| Approach | Time you get | Best for | Trade-off |
|---|---|---|---|
| All work inside the custom code action | < 20s wall-clock, 128 MB | Fast, self-contained logic with no slow external calls | Times out on any slow I/O |
| Action enqueues, worker patches back (this article) | Action returns in < 1s; worker runs as long as needed | Slow enrichment / external APIs, large per-record work | You run/maintain the worker |
| Extension action (separate API) with BLOCK + callback | Pauses enrollment, default up to ~1 week | Need the async result back inside the same workflow | Not the in-app editor — a heavier public Automation API build |
| Webhook + your own service | Unlimited — your runtime | Long-running, stateful, or business-critical logic | Most infrastructure to own |
A note on runtimes and plan
Custom code actions support JavaScript on the Node.js runtime and Python (beta), and require a Data Hub (formerly Operations Hub, rebranded at INBOUND 2025) Professional or Enterprise subscription — the tier requirement did not change with the rename. On versions: HubSpot has moved its serverless runtime to Node.js 20+, and Node 18 support for serverless functions ended October 1, 2025. The canonical custom-code-actions doc does not pin a runtime version, so treat that as a directional, as-of-2026-06-22 note rather than a guaranteed pin — write modern Node and label runtime assumptions with a date.
FAQ
Can I increase the 20-second timeout on a HubSpot custom code action?
No. As of 2026-06-22, HubSpot documents the 20-second execution limit and 128 MB memory limit as fixed and non-configurable. There is no setting, async flag, or support request that raises them — they exist because the action runs on HubSpot-managed AWS Lambda. The only durable fix is to move slow work out of the action.
What exactly does “Task timed out after 20.02 seconds” mean?
It means the action exceeded its hard 20-second wall-clock budget. The documented limit is 20 seconds; the extra .02 is Lambda overhead reflected in the error string, not a usable margin. The clock includes time spent awaiting external HTTP calls, so a slow third-party API is the most common cause.
Why does the timeout fire even though my own code is fast?
The 20-second clock includes time spent awaiting external HTTP responses. A slow third-party API or a loop over many associated records blocks the action on I/O, and that wait counts against your budget even though your CPU work is trivial. Time the calls first, then offload whatever is slow.
Does HubSpot retry a failed custom code action?
Yes — but only if you throw the error. On a 429 or 5XX, HubSpot reattempts the whole action for up to three days, starting one minute after failure, with intervals growing to a maximum eight-hour gap. Because the entire action re-runs, every downstream effect must be idempotent, keyed on the enrolled object’s id.
How do I stop retries from double-applying my work?
Make every write idempotent. Use the enrolled object id from the event payload (optionally plus a stable operation name) as an idempotency key, dedupe on it in your worker, and prefer property updates that set the same value on re-run rather than create-style operations that duplicate.
We’re an 8-person senior engineering shop that builds production HubSpot CRM/CMS integrations and API plumbing — offload workers, idempotent writes, and rate-limit handling included.
Talk to our HubSpot team →
The 20-second timeout isn’t a bug to work around — it’s a boundary telling you the work doesn’t belong inside the action. Validate fast, offload the rest, key everything on the enrolled object id, and let your own service own the slow path. If you’d rather hire a HubSpot developer to design and ship that architecture safely, contact our team and we’ll map it to your workflows.