Blog
Promise.all Is Not a Plan
- typescript
- node
- php
- laravel
- integrations
- queues
Here's a script that looks finished:
const customers = await getCustomersToSync();
await Promise.all(
customers.map((c) => crm.updateContact(c.id, toCrmPayload(c)))
);In dev, customers has twenty rows. It runs in a second. Ship it.
In production, customers has five thousand rows. Now you're opening five thousand requests to a vendor in the same instant, and wondering why half of them come back as 429s, timeouts, or connection resets.
The code didn't change. The number did.
Promise.all doesn't start anything
This is the part that trips people up. Promise.all is not a scheduler. It doesn't run your work. It just waits for it.
The work starts the moment you call crm.updateContact(). And .map() calls it for every item, right away, before Promise.all ever sees the array.
So "run these in parallel" really means "run all of these right now, no matter how many there are." There's no throttle in there. There was never going to be one.
What five thousand at once actually does
Some combination of:
- The vendor rate limits you, and now most of the batch failed.
- Your process holds thousands of open sockets and pending responses in memory.
- Every one of those calls that touches your database lines up behind a small connection pool.
- Requests time out while they're still waiting in line, not because the work was slow.
None of that shows up with twenty rows. All of it shows up at 2am.
Put a number on it
The fix is boring. Decide how many things can be in flight at once, and never go over it.
You don't need a framework for this. A few lines will do:
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
await Promise.all(workers);
return results;
}
await mapLimit(customers, 5, (c) => crm.updateContact(c.id, toCrmPayload(c)));Five workers pull from the same list until it's empty. At most five requests are ever open. JavaScript runs this on one thread, so next++ doesn't need a lock.
If you'd rather not own that function, p-limit does the same job:
import pLimit from 'p-limit';
const limit = pLimit(5);
await Promise.all(
customers.map((c) => limit(() => crm.updateContact(c.id, toCrmPayload(c))))
);Same shape as the original. One extra wrapper. Now it has a ceiling.
Fail fast doesn't mean stop
Another surprise: when one promise in Promise.all rejects, Promise.all rejects right away. The other requests don't care. They keep running. You just stopped listening to them.
So you get an error, you think the batch stopped, and meanwhile a few thousand writes are still landing on the vendor's side.
For batch work, you usually want to know what failed, not just that something did:
const results = await Promise.allSettled(
customers.map((c) => limit(() => crm.updateContact(c.id, toCrmPayload(c))))
);
const failed = customers.filter((_, i) => results[i].status === 'rejected');
console.warn(`${failed.length} of ${customers.length} contacts failed to sync`);Now you have a list you can retry, log, or hand to a human. That beats "something broke, good luck."
And if you really do need to stop everything when one call fails, that's a job for an AbortController you pass into each request. Promise.all won't do it for you.
The forEach trap
While we're here:
customers.forEach(async (c) => {
await crm.updateContact(c.id, toCrmPayload(c));
});
console.log('done'); // it is not doneforEach doesn't wait for async callbacks. This fires everything at once, just like the Promise.all version, except now you can't even await it or catch its errors.
If you want one at a time, use a plain for...of with await. If you want some at a time, use a limit.
PHP has the same problem
PHP is less likely to blast five thousand requests at once by accident, because most PHP code is happily sequential. But the moment you reach for concurrency, the same rule applies.
Guzzle's Pool has had a concurrency option for a long time:
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
$client = new Client(['timeout' => 10]);
$requests = function (iterable $customers) {
foreach ($customers as $customer) {
yield $customer->id => new Request(
'PUT',
"https://crm.example.com/contacts/{$customer->crm_id}",
['Content-Type' => 'application/json'],
json_encode(toCrmPayload($customer)),
);
}
};
$pool = new Pool($client, $requests($customers), [
'concurrency' => 5,
'fulfilled' => fn ($response, $customerId) => markSynced($customerId),
'rejected' => fn ($reason, $customerId) => Log::warning('CRM sync failed', [
'customer_id' => $customerId,
'error' => $reason->getMessage(),
]),
]);
$pool->promise()->wait();The generator means you aren't building five thousand request objects up front, and the key you yield comes back in the callbacks, so you know exactly which customer failed.
Queues need a ceiling too
The other common Laravel move is to dispatch one job per customer. That's a good instinct. It's also the same problem wearing a different hat.
Dispatch five thousand jobs, run twenty workers, and you've got twenty concurrent calls to the CRM. Scale up the workers for some unrelated reason next month, and now it's sixty. The vendor never agreed to that.
Laravel's Redis funnel caps how many of a job can run at once, no matter how many workers you have:
use Illuminate\Support\Facades\Redis;
public function handle(): void
{
Redis::funnel('crm-sync')
->limit(5)
->block(10)
->then(function () {
$this->crm->updateContact($this->customer);
}, function () {
$this->release(10);
});
}Five at a time across the whole fleet. Everyone else waits their turn and tries again.
Picking the number
There's no magic value. Some starting points:
- If the vendor documents a rate or concurrency limit, start below it. Their docs are the contract.
- If your own database is on the other end, stay well under your connection pool size.
- Start small. Five is a fine first guess for most third-party APIs.
- Measure, then raise it. Going from 5 to 10 is a config change. Recovering from getting your API key throttled is a support ticket.
The point isn't to find the perfect number. The point is to have one at all.
The short version
Promise.allwaits. It doesn't throttle..map()starts every call immediately.- Cap concurrency with a small worker loop,
p-limit, Guzzle'sPool, or a Redis funnel. - Use
allSettledfor batches so you know what failed. forEach(async ...)is a fire-and-forget bug.- Pick a number on purpose, before production picks one for you.
Twenty rows will never tell you this. Five thousand will, loudly.