Pattern
How to enrich a user on signup without slowing it down
Call Triguna once at account creation, keep it off the signup request path, backfill asynchronously, and handle the failures that would block a new user.
Enrichment on signup is the most common integration shape, and the most commonly done wrong. The mistake is putting a call that can take tens of seconds inside a request a human is waiting on.
The rule: never on the request path
Responses can be slow. 60 seconds is a safe client timeout, not a typical latency. A signup request must not wait for one.
// POST /signup
export async function signup(input: SignupInput) {
const user = await db.users.create(input); // fast, synchronous
await queue.publish('enrich.user', { // fire and forget
userId: user.id,
companyDomain: domainOf(input.email),
});
return user; // the human is done waiting
}
Everything downstream, whether routing, scoring, or personalization, reads enrichment when it exists and degrades gracefully when it does not. If a feature cannot degrade, it does not belong in the signup flow.
Resolve an identifier before you spend a call
Both endpoints key off slugs. If all you have is an email domain, you do not yet have an
identifier, and calling with a company display name returns 404 every time.
function companySlug(input: SignupInput): string | null {
// Best case: the user gave you a company URL.
if (input.companyUrl) return slugFrom(input.companyUrl, 'company');
// Otherwise you have a resolution problem, not an enrichment problem.
return null;
}
The worker
export async function enrichUser(job: { userId: string; companyDomain: string }) {
const user = await db.users.find(job.userId);
const slug = companySlug(user);
if (!slug) {
await db.users.markEnrichment(user.id, 'unresolvable');
return; // not a failure, there was nothing to call with
}
const outcome = await fetchCompany(slug); // handles 429 + Retry-After internally
if (!outcome.ok) {
if (outcome.kind === 'not_found') {
await db.users.markEnrichment(user.id, 'not_found');
return; // terminal: do not retry a slug bug
}
if (outcome.kind === 'auth') {
alert('triguna credentials or credits'); // page someone; retrying will not help
return;
}
throw new Error(outcome.detail); // let the queue retry server errors
}
await db.companies.upsert(mapCompany(outcome.record));
await db.users.markEnrichment(user.id, 'done');
}
Three distinct terminal states (unresolvable, not_found, done) instead of a boolean. When
someone asks “why does this account have no company data”, the answer is already stored.
Idempotency
Queues redeliver. Send an Idempotency-Key derived from the entity, not from the attempt, so a
redelivery is not billed twice:
headers: {
ApiKey: KEY,
'Idempotency-Key': `signup:${user.id}:company`,
}
Keying on the attempt (crypto.randomUUID()) defeats the point, because every retry becomes a
new billable call.
Cap the blast radius
A signup spike is a call spike. Two cheap protections:
- A per-key credit ceiling. Past the ceiling the API
returns
402instead of silently spending. That is a monitoring event, not an outage. - A queue concurrency limit. Pace calls rather than bursting; there is no bulk endpoint, so a thousand signups is a thousand calls whatever you do, and the only question is over how long.
What good looks like in metrics
| Metric | Watch for |
|---|---|
enrich.queued vs enrich.done | A widening gap means the worker is behind, not that the API is down |
not_found_rate | A step change means your slug extraction broke |
rate_limited_rate | Sustained non-zero means your concurrency is too high |
| p95 signup latency | Must be unchanged by enrichment. If it moved, something is on the request path |
That last row is the one worth alerting on. It is the only failure the user actually feels.
Next
- Nightly backfill: for the accounts that signed up before you built this.
- Handling partial records: designing the downstream reads.
- Conventions and errors: the retry semantics in full.