The AttiBooks sync kept timing out. Here is what was actually wrong.
AttiBooks is live on the Attio Marketplace. It keeps your Attio companies and your QuickBooks customers in sync, creates the invoice in QuickBooks when a deal moves to Won, and pulls the payment status back into Attio. I wrote about why I built it already.
This post is about the part I did not write up. For a few days in April, the thing did not work. The invoice sync kept timing out. It would start, churn for a while, and return nothing.
The reason was not QuickBooks. It was not a slow API. It was an assumption I made about where the code runs.
Where AttiBooks actually runs
Most integrations live on a server you control. You write a loop, the loop takes as long as it takes, and nobody minds if it runs for twelve seconds.
AttiBooks does not work that way. The sync runs inside Attio as a server function. It is part of the Attio app, not a backend I host. That choice keeps the product simple: there is no separate service to deploy, no infrastructure for a customer to trust, the logic lives next to the data.
The trade is that a server function has an execution budget. It gets a fixed slice of time, and if you go past it, the platform kills the request. The transport times out and you get nothing back.
I knew that in theory. I did not design around it.
The bug
The full backfill synced the 50 most recent invoices in one run. It walked them one at a time. Each invoice costs about two Attio API calls: a query to find the matching record, then the upsert to write it.
So one run was roughly 100 sequential API calls, each waiting for the last to finish. At around 200 milliseconds per upsert, that is several seconds of doing nothing but waiting, before you count the queries. On a small QuickBooks account it squeaked through. On a real one it blew straight past the server function budget and died.
The worst part: it failed quietly. A timeout is not a clean error you can catch. The function just stopped existing mid-loop. Some invoices synced, the rest did not, and the result told you nothing useful about why.
The fix that did not fix it
My first instinct was to do less work, not work differently. So I cut calls.
I hoisted the invoice-object setup out of the loop. On the first run AttiBooks creates the invoice object and its attributes in the customer's workspace, and I had that check sitting inside the per-invoice loop. It only needs to happen once. I moved it out.
I dropped a redundant lookup. The code was calling a find before every upsert, and the upsert already does find-and-update internally. That lookup was pure waste.
I capped the backfill at the 50 most recent invoices, ordered by last update.
This helped. Fewer calls per run, less obvious waste. But it was still a serial loop. I had made the slow thing shorter without making it not slow. On a busy account it still timed out.
The fix that shipped
The real problem was the word "sequential," not the word "many." So I stopped going one at a time.
The loop now runs upserts in parallel batches. Twenty invoices per run, five upserts in flight at once.
const BATCH_SIZE = 5
for (let i = 0; i < invoices.length; i += BATCH_SIZE) {
const batch = invoices.slice(i, i + BATCH_SIZE)
const outcomes = await Promise.all(
batch.map(async (invoice) => {
try {
await upsertInvoiceRecord(mapQboInvoiceToRecord(invoice, realmId, apiBase))
return { ok: true }
} catch (err) {
return { ok: false, id: invoice.Id, msg: String(err) }
}
})
)
// tally synced and errors from outcomes
}Effective API calls dropped from around 100 sequential to around 40 with five-way concurrency. The run finishes well under the budget. And because every upsert reports its own outcome, a single bad invoice no longer takes the rest down with it. It lands in an errors list and the others keep going.
What I actually got wrong
The bug was not in QuickBooks and it was not in Attio. Both behaved exactly as documented.
The bug was that I treated a metered runtime like an unmetered one. I wrote the loop the way you write a loop on your own server, where time is free, and then I ran it somewhere time is not free. The platform's limit was not an edge case to handle later. It was a constraint that should have shaped the design from the first line.
That is the lesson I keep relearning when I build on top of someone else's platform. Their limits are not footnotes. Their limits are your architecture.
AttiBooks is syncing in batches now, on the Marketplace, doing the boring thing it is supposed to do. The boring part took the most work.
Need help with your CRM?
Messy data, manual processes, or a CRM that doesn't fit? Let's talk.
Book a call