X API in Node.js: Typed Errors and Honest Concurrency
X API in Node.js: Typed Errors and Honest Concurrency
Most X API examples are Python. That's fine until you're in a Node service and have to decide how to handle a 429 inside a Promise.all, which is where the interesting problems are.
Node brings two things Python doesn't: a type system worth using on API responses, and an async model that makes it very easy to accidentally fire two hundred concurrent requests. This covers both, plus the pagination pattern that reads better in JS than it does anywhere else.
There's a free demo endpoint if you want to check response shapes against your types before writing the client.
The setup
npm i @socialapitech/sdk
import { SocialAPI } from "@socialapitech/sdk";
const api = new SocialAPI("sk_your_key"); // or set SOCIALAPI_KEY
const user = await api.user_info("elonmusk");
console.log(user.followers_count);
Node 18+ for native fetch, which also means it runs unchanged in Deno and Bun. If you'd rather not add a dependency, the API is plain HTTP with a header — a bare fetch wrapper is twenty lines and the rest of this article still applies.
Branch on error type, not status code
This is the part that actually matters, and it's where most Node clients get it wrong.
A 429 and a 402 are both "your request failed," and code that treats them the same way is broken in one of two directions: either it retries a 402 forever, or it gives up on a 429 that would have succeeded.
import { SocialAPI, RateLimited, InsufficientCredits, SocialAPIError }
from "@socialapitech/sdk";
try {
const tweets = await api.user_last_tweets("naval", { limit: 50 });
process(tweets);
} catch (err) {
if (err instanceof RateLimited) {
// 429 — not charged. Waiting fixes this.
await sleep(5_000);
} else if (err instanceof InsufficientCredits) {
// 402 — deterministic. Retrying burns time and changes nothing.
await alertOps("out of credits");
throw err;
} else if (err instanceof SocialAPIError) {
console.error(`API error ${err.status}:`, err.message);
throw err;
} else {
throw err; // not ours — network, parse, your bug
}
}
The distinction worth internalising: a 429 is temporary and costs nothing, a 402 is permanent until you act. Rate limits aren't billed, so a retry is free in both senses. Credit exhaustion will return 402 on every attempt until someone tops up — retrying it is a loop that never exits.
The SDK already retries 429 and 5xx twice by default and never retries 402, so the handler above is for what's left after that. If you want different behaviour:
const api = new SocialAPI({
apiKey: process.env.SOCIALAPI_KEY,
maxRetries: 4, // 429/5xx only
timeoutMs: 60_000,
});
Being explicit about this is worth a line of config, because the default that suits a batch job rarely suits a request handler. Which of the three kinds of limit you've hit is worth knowing too — what rate limit exceeded means separates them.
Concurrency: the mistake Promise.all invites
// Don't. This fires 500 requests at once.
const users = await Promise.all(handles.map(h => api.user_info(h)));
With ten handles that's fine. With five hundred you've opened five hundred sockets, and you'll see timeouts and rate limits that look like API problems but are self-inflicted.
A bounded pool, with no dependency:
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<(R | Error)[]> {
const results: (R | Error)[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (true) {
const i = next++; // single-threaded: no race here
if (i >= items.length) return;
try {
results[i] = await fn(items[i]);
} catch (e) {
results[i] = e as Error; // keep position, don't abort the batch
}
}
};
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker)
);
return results;
}
const results = await mapLimit(handles, 5, h => api.user_info(h));
Two details carry the weight here.
const i = next++ is safe because JavaScript is single-threaded — the increment can't interleave. The same pattern in Go or Java needs a mutex. This is one of the rare places where Node's model makes the correct code shorter.
Storing errors at their index instead of throwing means one bad handle doesn't discard 499 good results. Promise.all rejects on the first failure and abandons everything else, which is almost never what you want for a batch. Promise.allSettled fixes the aborting but still fires everything at once.
If the endpoint has a batch form, use that instead of concurrency. One request for a hundred profiles beats a hundred requests at any concurrency level — the SDK exposes batch_user_info and tweets_batch for exactly this, and batching requests covers when it pays off.
Pagination as an async iterator
First, know which endpoints page at all. Timelines don't: user_last_tweets returns up to limit posts (maximum 200) in one call, with no cursor. Lists do: followers, followings, retweeters and list members return a next_cursor in meta.
For the ones that page, this is where JS genuinely reads better than the alternatives. Instead of a loop that accumulates into an array, expose a stream the caller consumes lazily. It reads meta from the raw response, so it's written against fetch directly:
const BASE = "https://api.socialapi.tech/v1";
const HEADERS = { "X-API-Key": process.env.SOCIALAPI_KEY! };
async function* followers(
username: string,
pageSize = 200
): AsyncGenerator<User, void, undefined> {
let cursor: string | undefined;
while (true) {
const qs = new URLSearchParams({ username, limit: String(pageSize) });
if (cursor) qs.set("cursor", cursor);
const res = await fetch(`${BASE}/user/followers?${qs}`, { headers: HEADERS });
if (!res.ok) throw new Error(`followers: HTTP ${res.status}`);
const body = await res.json();
const page: User[] = body.data ?? [];
if (!page.length) return;
yield* page;
cursor = body.meta?.next_cursor;
if (!cursor) return;
}
}
// the caller never holds more than one page in memory
for await (const user of followers("naval")) {
await handle(user);
if (someStopCondition(user)) break; // ← stops fetching immediately
}
The break is the whole point. A function that returns an array must fetch everything before you see any of it. An async generator stops making requests the moment the consumer stops consuming — so "find the first follower matching X" costs one page, not the whole list.
That also means memory stays flat regardless of how deep you page, which matters when a follower list runs to millions.
Typing responses without lying to yourself
The SDK ships declarations, so returns are typed. The temptation is to then treat those types as guarantees:
// optimistic — fine for your own dashboard, risky in a pipeline
const bio: string = user.bio;
Field presence is a runtime property, not a compile-time one. A type assertion doesn't validate anything — if bio is absent, string is simply wrong and you find out downstream. For anything that feeds storage or another service, validate at the boundary:
import { z } from "zod";
const TweetRow = z.object({
id: z.string(), // string, not number — 19-digit IDs
text: z.string(),
created_at: z.string(),
like_count: z.number().int().nonnegative(),
view_count: z.number().int().nonnegative().optional(),
});
const parsed = TweetRow.safeParse(raw);
if (!parsed.success) {
logger.warn({ id: raw?.id, issues: parsed.error.issues }, "unexpected shape");
return; // skip the row, keep the job alive
}
id must be z.string(). Tweet IDs are 19-digit snowflake values above Number.MAX_SAFE_INTEGER (2^53 − 1), so parsing one as a number silently corrupts the last digits — and JSON.parse will do exactly that if the API ever sends it unquoted. This is the single most common data-integrity bug in JS clients for X, and it fails quietly. The full field layout is in reading the tweet object.
// why it matters
Number("1794060407879118301") // → 1794060407879118300 ← last digit gone
BigInt("1794060407879118301") // → 1794060407879118301n ← exact
Validating also gives you a place to put the logger.warn — a shape change becomes a log line rather than a crash, which is what you want in something that runs unattended.
Running this in a serverless function
Two things differ from a long-lived process:
Construct the client outside the handler. Module scope persists across warm invocations; constructing per request throws away connection reuse for no reason.
Don't start a stream. WebSockets want a process that stays alive, and a function doesn't have one. If you need push delivery into a serverless target, that's what webhooks are for — the three mechanisms compared covers choosing between them.
// module scope — reused across warm invocations
const api = new SocialAPI({ apiKey: process.env.SOCIALAPI_KEY });
export async function handler(event) {
const user = await api.user_info(event.username);
return { statusCode: 200, body: JSON.stringify(user) };
}
The SDK is generated from our OpenAPI spec, so the method names and types track the API rather than drifting from it. Signing up with Google includes $0.20 in free credits and no card — enough to run everything above against real responses.
FAQ
Is there an official X API client for Node? X publishes clients for its own API, which is primarily built around write access and billed per resource returned. For read-only data, third-party SDKs are usually a better fit on both interface and price.
How do I use the X API with TypeScript?
Install an SDK that ships declarations, or wrap fetch and validate responses with a schema library. Either way, validate at the boundary — typed returns aren't runtime guarantees.
Do I need node-fetch?
No, not on Node 18 or later — fetch is global. That also means the same code runs in Deno, Bun, and edge runtimes without changes.
Why should I catch error types instead of checking status codes? Because the right response differs by type. A 429 is temporary and uncharged, so retry; a 402 is deterministic until you top up, so retrying is an infinite loop. Status-code equality tends to collapse that distinction.
Is a 429 charged? No. Rate-limited requests aren't billed, which is why backing off and retrying is the correct response rather than an expensive one.
Should I retry a 402? Never. It means you're out of credits and will keep meaning that until someone tops up. Alert instead of retrying.
What's a safe default for retries? Two attempts on 429 and 5xx, with no retries on 4xx other than 429. That's what the SDK does by default, and it's a reasonable starting point for a batch job.
Why is Promise.all dangerous here? Two reasons: it fires every request simultaneously, and it rejects on the first failure while discarding the rest. For a batch of five hundred you want bounded concurrency and per-item error capture.
What concurrency should I use? Start around 5 and raise it while watching for 429s. The right number depends on your plan and the endpoint; there's no universal value worth copying.
Is Promise.allSettled a fix? It fixes the aborting but not the stampede — all requests still start at once. You need a bounded pool for that.
Why is next++ safe without a lock?
Because JavaScript executes on a single thread, so the read-increment-write can't interleave. The identical pattern in a threaded language would need a mutex.
Should I use a concurrency library?
p-limit is fine and well tested. The fifteen-line version above exists so you can see what it does — and it avoids a dependency if that matters to you.
How do I paginate in Node?
Only list endpoints need it — timelines return everything up to limit in one call. For followers and lists, an async generator: the caller gets a for await loop, memory stays flat, and break stops fetching immediately instead of after everything has been downloaded.
Why an async iterator instead of returning an array? Because an array forces you to fetch everything before the caller sees anything. With an iterator, "find the first match" costs one page rather than the whole list.
Does breaking out of a for-await loop stop the requests?
Yes — the generator suspends at its yield and never runs the next fetch. That's the property that makes early exit cheap.
Why must tweet IDs be strings in TypeScript?
They're 19 digits, above Number.MAX_SAFE_INTEGER. Number("1794060407879118301") returns a value ending in ...300 — wrong, with no error. Keep them as strings, or use BigInt if you need arithmetic.
Does JSON.parse corrupt large IDs? It will if the number arrives unquoted, because it parses into a double. Well-behaved APIs send IDs as strings for this reason; validating that they're strings catches it if one ever doesn't.
Do I need Zod if the SDK is typed? Not for a dashboard you're watching. Yes for anything writing to storage or feeding another service, because a type annotation validates nothing at runtime.
How do I handle an unexpected response shape? Validate, log the failure with the row's ID, and skip that row. A long-running job shouldn't die because one record was odd.
Where should I construct the client in a serverless function? Module scope, so warm invocations reuse it. Constructing it inside the handler discards connection reuse for no benefit.
Can I run a WebSocket stream in a Lambda? Not well — streams need a long-lived process. Use a webhook for serverless targets; that's the case it exists for.
How do I set a timeout per request?
Via the client option, or an AbortController if you're using bare fetch. Don't leave it unbounded in a request handler — a hung upstream becomes your outage.
Does this work in a browser? Technically, but don't — it would expose your API key to every visitor. Proxy through your own backend and keep the key server-side.
Should I use ESM or CommonJS?
ESM. The package is ESM-only ("type": "module"), which is also what every current runtime prefers.
How do I check my remaining credits from Node? There's an account endpoint for balance and usage, and it's free to call. Worth polling in a batch job so you fail on your own terms rather than mid-run.
What's the most common mistake in Node clients for X? Parsing tweet IDs as numbers. It silently corrupts data, survives testing on small samples, and surfaces much later as rows that don't join.
If you take one thing from this: keep IDs as strings, bound your concurrency, and branch on error type. A Google sign-up includes $0.20 in free credits and no card, which is enough to run the examples above and see the typed errors fire for real.