Linear Performance Tuning
Overview
Optimize Linear API usage for minimal latency and efficient resource consumption. The three main levers are: (1) query flattening to avoid N+1 and reduce complexity, (2) caching static data with webhook-driven invalidation, and (3) batching mutations into single GraphQL requests.
Key numbers:
- Query complexity budget: 250,000 pts/hour, max 10,000 per query
- Each property: 0.1 pt, each object: 1 pt, connections: multiply by
first
- Best practice: sort by
updatedAt to get fresh data first
Prerequisites
- Working Linear integration with
@linear/sdk
- Understanding of GraphQL query structure
- Optional: Redis for distributed caching
Instructions
Step 1: Eliminate N+1 Queries
The SDK lazy-loads relations. Accessing .assignee on 50 issues makes 50 separate API calls.
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });
const issues = await client.issues({ first: 50 });
for (const i of issues.nodes) {
const assignee = await i.assignee;
const state = await i.state;
console.log(`${i.identifier}: ${assignee?.name} [${state?.name}]`);
}
const response = await client.client.rawRequest(`
query TeamDashboard($teamId: String!) {
team(id: $teamId) {
issues(first: 50, orderBy: updatedAt) {
nodes {
id identifier title priority estimate updatedAt
assignee { name email }
state { name type }
labels { nodes { name color } }
project { name }
}
pageInfo { hasNextPage endCursor }
}
}
}
`, { teamId: "team-uuid" });
Step 2: Cache Static Data
Teams, workflow states, and labels change rarely. Cache them with appropriate TTLs.
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class LinearCache {
private store = new Map<string, CacheEntry<any>>();
get<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry || Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.data;
}
set<T>(key: string, data: T, ttlSeconds: number): void {
this.store.set(key, { data, expiresAt: Date.now() + ttlSeconds * 1000 });
}
invalidate(key: string): void {
this.store.delete(key);
}
}
const cache = ();
() {
cached = cache.<[]>();
(cached) cached;
teams = client.();
cache.(, teams., );
teams.;
}
() {
key = ;
cached = cache.<[]>(key);
(cached) cached;
team = client.(teamId);
states = team.();
cache.(key, states., );
states.;
}
() {
cached = cache.<[]>();
(cached) cached;
labels = client.();
cache.(, labels., );
labels.;
}
Step 3: Webhook-Driven Cache Invalidation
Replace polling with webhooks. Invalidate cache when relevant entities change.
function handleCacheInvalidation(event: { type: string; action: string; data: any }) {
switch (event.type) {
case "Issue":
cache.invalidate(`issue:${event.data.id}`);
break;
case "WorkflowState":
cache.invalidate(`states:${event.data.teamId}`);
break;
case "IssueLabel":
cache.invalidate("labels");
break;
case "Team":
cache.invalidate("teams");
break;
}
}
Step 4: Batch Mutations
Combine multiple mutations into one GraphQL request.
async function batchUpdatePriority(
client: LinearClient,
issueUpdates: Array<{ id: string; priority: number }>
) {
const chunkSize = 20;
for (let i = 0; i < issueUpdates.length; i += chunkSize) {
const chunk = issueUpdates.slice(i, i + chunkSize);
const mutations = chunk.map((u, j) =>
`u${j}: issueUpdate(id: "${u.id}", input: { priority: ${u.priority} }) { success }`
).join("\n");
await client.client.rawRequest(`mutation { ${mutations} }`);
}
}
async function batchCreate(
client: LinearClient,
teamId: string,
issues: Array<{ title: string; priority?: number }>
) {
const mutations = issues.map(
Step 5: Efficient Pagination
async function* paginateIssues(
client: LinearClient,
teamId: string,
pageSize = 50
) {
let cursor: string | undefined;
let hasNext = true;
while (hasNext) {
const result = await client.issues({
first: pageSize,
after: cursor,
filter: { team: { id: { eq: teamId } } },
orderBy: "updatedAt",
});
yield result.nodes;
hasNext = result.pageInfo.hasNextPage;
cursor = result.pageInfo.endCursor;
}
}
for await (const batch of paginateIssues(client, "team-uuid")) {
console.log(`Processing ${batch.length} issues`);
}
const lastSync = "2026-03-20T00:00:00Z";
const updated = client.({
: ,
: { : { : lastSync } },
: ,
});
Step 6: Request Coalescing
Deduplicate concurrent identical requests.
const inflight = new Map<string, Promise<any>>();
async function coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
if (inflight.has(key)) return inflight.get(key)!;
const promise = fn().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
const team = await coalesce("team:ENG", () =>
client.teams({ filter: { key: { eq: "ENG" } } }).then(r => r.nodes[0])
);
Error Handling
| Error | Cause | Solution |
|---|
Query complexity too high | Deep nesting + large first | Use rawRequest() with flat fields, first: 50 |
| HTTP 429 | Burst exceeding rate budget | Add request queue with 100ms spacing |
| Stale cache | TTL too long | Shorten TTL or use webhook invalidation |
| Timeout | Query spanning too many records | Paginate with first: 50 + cursor |
Examples
Performance Benchmark
async function benchmark(label: string, fn: () => Promise<any>) {
const start = Date.now();
await fn();
console.log(`${label}: ${Date.now() - start}ms`);
}
await benchmark("Cold teams", () => client.teams());
await benchmark("Cached teams", () => getTeams(client));
await benchmark("50 issues (SDK)", () => client.issues({ first: 50 }));
await benchmark("50 issues (raw)", () => client.client.rawRequest(
`query { issues(first: 50) { nodes { id identifier title priority } } }`
));
Resources