Node.js is the natural home for TikTok data work. Almost every step of a scraper, from fetching profiles to writing CSVs, is I/O bound, and Node's event loop turns those waits into easy concurrency. Add a stable JSON API on top and you can build a follower tracker, a content dashboard, or an ETL pipeline in a single evening, with no headless browser and no scraping middleware to babysit.
This tutorial mirrors our Python walkthrough, but rewritten with modern Node idioms: built-in fetch, async/await, async generators, and zero third-party HTTP libraries. By the end you will have a production-ready follower tracker that pulls live data from TikLiveAPI, retries on transient failures, runs requests in parallel with controlled concurrency, and writes a clean CSV you can import into any BI tool.
Python is great, but Node has three concrete advantages for this kind of work:
Promise.all, no asyncio dance required.fetch, so you do not need axios, got, or node-fetch for simple JSON work.Combine that with TikLiveAPI's flat credit pricing (1 request = 1 credit, no monthly minimum) and the 37-endpoint surface area is enough to power most TikTok analytics products without ever touching a browser automation tool.
fetch, AbortController, and async generators. Check with node --version.dotenv if you prefer a .env file over shell exports.Create a project folder and initialize it:
mkdir tiktok-scraper && cd tiktok-scraper
npm init -y
npm pkg set type=module
The "type": "module" entry lets us use import syntax and top-level await, which keeps the code closer to what you would write in a TypeScript or Next.js project.
Find your key on the profile page after logging in. Never paste it into source code. Export it to your shell instead:
export TIKLIVEAPI_KEY="your_actual_key_here"
Then build a tiny wrapper that every other step will reuse. Create client.js:
const BASE_URL = "https://api.tikliveapi.com";
const API_KEY = process.env.TIKLIVEAPI_KEY;
if (!API_KEY) {
throw new Error("Set TIKLIVEAPI_KEY in your environment before running.");
}
export async function tlaGet(path, params = {}) {
const url = new URL(BASE_URL + path);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) url.searchParams.set(k, v);
}
const res = await fetch(url, {
headers: {
"X-Api-Key": API_KEY,
"Accept": "application/json"
}
});
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status} on ${path}: ${body.slice(0, 200)}`);
}
return res.json();
}
Two things to note. First, the auth header is X-Api-Key with the literal key, not a Bearer token. Second, every endpoint uses GET with query string params, so a single helper covers the whole API surface.
The /userinfo-by-username/ endpoint is the most common starting point. Give it a username param and it returns a camelCase object with two top-level keys: user (identity and bio fields) and stats (counts).
import { tlaGet } from "./client.js";
const data = await tlaGet("/userinfo-by-username/", { username: "tiktok" });
console.log(data.user.nickname); // display name
console.log(data.user.uniqueId); // @handle
console.log(data.user.verified); // boolean
console.log(data.stats.followerCount); // followers
console.log(data.stats.heartCount); // total likes received
console.log(data.stats.videoCount); // public posts
Useful identity fields on user include id, uniqueId, nickname, avatarThumb, avatarMedium, avatarLarger, signature, verified, secUid, privateAccount, and bio links like ins_id, twitter_id, youtube_channel_title, youtube_channel_id, and bioLink. On stats you get followingCount, followerCount, heartCount, videoCount, and diggCount.
Posts are keyed by numeric user ID, not username. Resolve it once with /userid/, which returns the smallest payload in the API: a single id field as a string.
const { id: userid } = await tlaGet("/userid/", { username: "tiktok" });
console.log(userid); // e.g. "107955"
Now hit /user-posts/. The response wraps a snake_case videos array and also returns a cursor string and a hasMore boolean (note the camelCase). count defaults to 10 and maxes out at 35.
const page = await tlaGet("/user-posts/", { userid, count: 20 });
for (const v of page.videos) {
console.log({
id: v.video_id,
title: v.title,
plays: v.play_count,
likes: v.digg_count,
comments: v.comment_count,
shares: v.share_count,
duration: v.duration,
posted: new Date(v.create_time * 1000).toISOString()
});
}
console.log("More pages?", page.hasMore, "next cursor:", page.cursor);
Every video carries aweme_id, video_id, region, title, cover, ai_dynamic_cover, origin_cover, duration, the no-watermark URL under play, the watermarked variant under wmplay, file sizes, a nested music_info object, a nested author object, and unix-timestamp create_time. The convention is snake_case for the video fields and the pagination key, but hasMore stays camelCase. Code defensively against that mix.
Async generators are the cleanest way to express "keep fetching pages until hasMore is false." Callers can for await over the stream and stop whenever they want, and you do not have to allocate the full result set in memory.
export async function* iterateUserPosts(userid, { pageSize = 35, maxVideos = Infinity } = {}) {
let cursor = 0;
let fetched = 0;
while (true) {
const page = await tlaGet("/user-posts/", {
userid,
count: pageSize,
cursor
});
for (const video of page.videos ?? []) {
yield video;
fetched++;
if (fetched >= maxVideos) return;
}
if (!page.hasMore) return;
cursor = page.cursor;
}
}
// Usage:
let count = 0;
for await (const v of iterateUserPosts(userid, { maxVideos: 100 })) {
count++;
console.log(count, v.video_id, v.play_count);
}
Two production tips. Always cap with maxVideos so a runaway cursor cannot drain your credit balance. And remember that each page is one credit, so a creator with 500 videos fetched in pages of 35 costs 15 credits, not 500.
Transient 5xx responses, network hiccups, and rate-limit pushes (standard plans allow 200 requests per minute) all need a retry strategy. Here is a dependency-free helper with exponential backoff and full jitter:
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
export async function withRetry(fn, { attempts = 5, baseMs = 500, capMs = 8000 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const status = parseStatus(err);
// Do not retry client errors except 429
if (status && status >= 400 && status < 500 && status !== 429) throw err;
const exp = Math.min(capMs, baseMs * 2 ** i);
const wait = Math.floor(Math.random() * exp);
console.warn(`Attempt ${i + 1} failed (${err.message}). Retrying in ${wait}ms`);
await sleep(wait);
}
}
throw lastErr;
}
function parseStatus(err) {
const m = /^HTTP (\d{3})/.exec(err.message || "");
return m ? Number(m[1]) : null;
}
Wrap your existing calls with it:
const profile = await withRetry(() =>
tlaGet("/userinfo-by-username/", { username: "tiktok" })
);
The pattern is conservative on purpose. A 401 means your key is wrong, a 402 means you are out of credits, and a 404 means the username does not exist. Retrying any of those wastes time and burns credits. Only 429 and 5xx codes get another chance.
You can also harden tlaGet with an AbortController timeout so a single slow request never stalls the whole job:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, { headers, signal: controller.signal });
// ...
} finally {
clearTimeout(timer);
}
Promise.all is the obvious way to parallelize, but firing 500 requests at once will hit the 200 req/min ceiling and waste retries. A small concurrency limiter solves it without adding p-limit as a dependency:
export function pLimit(max) {
let active = 0;
const queue = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
const { fn, resolve, reject } = queue.shift();
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => { active--; next(); });
};
return (fn) => new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
// Usage: 5 concurrent profile fetches at most
const limit = pLimit(5);
const usernames = ["tiktok", "khaby.lame", "charlidamelio", "mrbeast", "zachking"];
const profiles = await Promise.all(
usernames.map(u => limit(() =>
withRetry(() => tlaGet("/userinfo-by-username/", { username: u }))
))
);
profiles.forEach(p => console.log(p.user.uniqueId, p.stats.followerCount));
Five concurrent requests is a safe default. With 750ms average latency you will saturate roughly 400 requests per minute, so dial it back to 3 if you want to stay well under the 200 req/min plan limit when other parts of your app are also hitting the API.
Let's tie everything together. The script below takes a list of usernames, fetches each profile, and appends a row to followers.csv with the date, username, follower count, video count, and total likes. Run it from cron every morning and you have a complete time series in a week.
import { writeFile, appendFile, access } from "node:fs/promises";
import { constants } from "node:fs";
import { tlaGet } from "./client.js";
import { withRetry, pLimit } from "./helpers.js";
const USERNAMES = ["tiktok", "khaby.lame", "charlidamelio", "mrbeast", "zachking"];
const OUTPUT = "followers.csv";
function csvEscape(value) {
const s = String(value ?? "");
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
async function ensureHeader() {
try {
await access(OUTPUT, constants.F_OK);
} catch {
await writeFile(OUTPUT, "date,username,followers,videos,likes\n");
}
}
async function snapshot(username) {
const data = await withRetry(() =>
tlaGet("/userinfo-by-username/", { username })
);
return {
date: new Date().toISOString().slice(0, 10),
username,
followers: data.stats.followerCount,
videos: data.stats.videoCount,
likes: data.stats.heartCount
};
}
await ensureHeader();
const limit = pLimit(3);
const rows = await Promise.all(
USERNAMES.map(u => limit(() => snapshot(u)))
);
const lines = rows
.map(r => [r.date, r.username, r.followers, r.videos, r.likes].map(csvEscape).join(","))
.join("\n") + "\n";
await appendFile(OUTPUT, lines);
console.log(`Wrote ${rows.length} rows to ${OUTPUT}`);
Five usernames cost five credits per run. A daily cron for a year is 1,825 credits, which is comfortably inside one credit pack. Pipe the CSV into a Postgres table or Google Sheets and you have a growth dashboard with zero infrastructure beyond a cron job.
The same client and patterns extend to the rest of the API. A few worth bookmarking:
video (no-watermark SD MP4) and video_hd (no-watermark HD MP4). The canonical no-watermark download endpoint.play (no-watermark), hdplay (no-watermark HD), and wmplay (watermarked) URLs plus the full metrics block.comments array; each item has text, digg_count, reply_total, and a nested user object.keyword plus optional publish_time, sort_by, and region filters; great for trend mining. See the search docs.cha_name, user_count, and view_count.followers and (note the s) followings respectively.Test any of them interactively without writing code in the interactive playground.
Do I really not need axios or node-fetch?
Right. Node 18+ implements the WHATWG fetch standard natively, including Headers, URL, URLSearchParams, and AbortController. Skip the dependency unless you specifically need axios interceptors or a streaming feature fetch does not cover.
What is the difference between aweme_id and video_id?
Both appear on every video object in /user-posts/ and /search-video/. They are TikTok's internal IDs and are usually identical strings; the API surfaces both so you can match either format. For storage, pick one and stick with it.
How do I avoid burning credits during development?
Cache responses to disk while you iterate. A 10-line wrapper around tlaGet that writes to cache/{md5(url)}.json and reads it back on the next run will turn a hundred test runs into one credit. Just make sure to bypass the cache in production.
Why is the response convention mixed (snake_case and camelCase)?
The endpoints relay TikTok's underlying data shapes. User profile responses come out camelCase (uniqueId, followerCount), while feeds and posts come out snake_case (video_id, play_count). Pagination keys on /user-posts/ add hasMore camelCase. The simplest fix is a normalizer function in your code layer.
Can I run this in a serverless function?
Yes. The whole client is ~30 lines and has zero dependencies, so it deploys cleanly to Vercel, Cloudflare Workers (use the Workers fetch), AWS Lambda, or Deno Deploy. Just inject TIKLIVEAPI_KEY as a secret env var and you are done.
You now have a Node-native TikTok scraper that handles retries, concurrency, pagination, and persistent CSV output. From here you can:
/user-posts/ with /post-comments/ to build a sentiment dashboard./download-video/ to archive the no-watermark MP4 of every new post.If you need higher volume or have questions about the response shapes, our contact form gets a reply within one business day. Top up credits anytime on the pricing page; there are no monthly minimums, unused credits never expire, and the model stays the same: 1 request equals 1 credit, predictably.
Ready to put what you read into code? Try our endpoints live or grab the full reference.