<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Vineeth Pawar - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Vineeth Pawar - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 20 Sep 2026 23:35:01 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/vpawar/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Get Reliable Structured Data Out of an LLM ]]>
                </title>
                <description>
                    <![CDATA[ Most tutorials about calling a language model end at JSON.parse(response.content). That line works on your first ten test cases. Then you ship, and somewhere around request four hundred the model retu ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-get-reliable-structured-data-out-of-an-llm/</link>
                <guid isPermaLink="false">6a908843ac9ce68300a83a4c</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Thu, 27 Aug 2026 18:56:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/be161971-7a90-496c-899a-526492046265.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most tutorials about calling a language model end at <code>JSON.parse(response.content)</code>. That line works on your first ten test cases. Then you ship, and somewhere around request four hundred the model returns a date it invented, or eight array items when your schema allows five, or a perfectly valid JSON object with one field quietly missing.</p>
<p>I ran into this while building Temploracraft, a résumé tool that takes an uploaded document and turns it into structured data the application can edit.</p>
<p>The input is genuinely unpredictable: two-column PDFs, tables that aren't really tables, and dates written in about fourteen different formats. The output has to be strict, because every extracted field lands in a form that a person is going to look at. When the model gets a date wrong, the user notices in about two seconds.</p>
<p>This article is about the layer that sits between "the model returned some text" and "my application has data it can trust." It covers the three mechanisms for constraining output, why designing the schema first beats writing a longer prompt, how to build a retry loop that doesn't set money on fire, and what to do about the failures that no retry will ever fix.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-prompting-for-json-isnt-enough">Why Prompting for JSON Isn't Enough</a></p>
</li>
<li><p><a href="#heading-the-three-ways-to-constrain-output">The Three Ways to Constrain Output</a></p>
</li>
<li><p><a href="#heading-start-with-the-schema-not-the-prompt">Start with the Schema, Not the Prompt</a></p>
</li>
<li><p><a href="#heading-validation-is-two-jobs-not-one">Validation Is Two Jobs, Not One</a></p>
</li>
<li><p><a href="#heading-building-a-retry-loop-that-doesnt-burn-tokens">Building a Retry Loop That Doesn't Burn Tokens</a></p>
</li>
<li><p><a href="#heading-streaming-structured-output">Streaming Structured Output</a></p>
</li>
<li><p><a href="#heading-the-failures-you-cant-retry-away">The Failures You Can't Retry Away</a></p>
</li>
<li><p><a href="#heading-what-this-actually-costs">What This Actually Costs</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-any-of-this">When You Don't Need Any of This</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along comfortably, you'll want a few things in place:</p>
<ul>
<li><p><strong>Working knowledge of TypeScript:</strong> The examples use type inference and generics lightly, and you should be able to read a type annotation without pausing.</p>
</li>
<li><p><strong>You have called a language model API at least once:</strong> You don't need to be an expert, but you should know what a system prompt is and roughly what a token is.</p>
</li>
<li><p><strong>Familiarity with JSON Schema is helpful but not required:</strong> I explain the parts that matter as they come up.</p>
</li>
<li><p><strong>Node.js 18 or later</strong> if you want to run the examples, since they use the native <code>fetch</code> and async iterators.</p>
</li>
</ul>
<p>The code samples use <a href="https://zod.dev">Zod</a> for schema definition and the Anthropic SDK for model calls, but every technique here translates directly to other validation libraries and other providers. The ideas matter more than the specific packages.</p>
<h2 id="heading-why-prompting-for-json-isnt-enough">Why Prompting for JSON Isn't Enough</h2>
<p>The first instinct when you need structured data is to ask for it politely. You write something like "Respond with valid JSON matching this shape, and do not include any other text," you paste an example, and it works. It keeps working through development. It works in your demo.</p>
<p>The problem is that a language model generates one token at a time based on probability, and your instruction is only one influence among many. It competes with the model's training, the shape of the input document, and whatever the model produced in the preceding few hundred tokens. Most of the time your instruction wins. Occasionally it does not.</p>
<p>Here are failures I've actually collected from a production extraction pipeline, all from a model that was explicitly told to return strict JSON:</p>
<ul>
<li><p>The model wrapped the response in a Markdown code fence, despite being told twice not to.</p>
</li>
<li><p>It returned <code>"2019 - Present"</code> as a single string where the schema defined separate <code>startDate</code> and <code>endDate</code> fields.</p>
</li>
<li><p>It invented an <code>endDate</code> of <code>"2024-12-31"</code> for a role the document clearly marked as current.</p>
</li>
<li><p>It returned the string <code>"null"</code> instead of an actual <code>null</code>.</p>
</li>
<li><p>It emitted seven bullet points for a role where the schema set a maximum of five.</p>
</li>
<li><p>It truncated mid-object because the response hit the output token limit.</p>
</li>
</ul>
<p>Notice that these aren't all the same kind of failure. The code fence and the truncation are syntax problems, and you can often fix them locally without calling the model again. The merged date string and the extra bullets are schema problems, where the JSON parses fine but doesn't match the shape you need. The invented end date is a semantic problem, where the output is both valid JSON and schema-conformant but factually wrong about the source document.</p>
<p>Those three categories need three different responses, and conflating them is the most common architectural mistake I see in extraction code. The rest of this article is largely about separating them.</p>
<h2 id="heading-the-three-ways-to-constrain-output">The Three Ways to Constrain Output</h2>
<p>Before writing any validation code, it's worth understanding what the API itself can enforce for you. There are three mechanisms, and they give you meaningfully different guarantees.</p>
<p><strong>JSON mode</strong> is the weakest of the three. You set a flag such as <code>response_format: { type: "json_object" }</code>, and the provider guarantees the response will be syntactically valid JSON. That's genuinely useful, because it eliminates the code fence and truncation problems in one move. What it doesn't do is guarantee anything about the shape. You can get valid JSON with the wrong keys, the wrong types, or a completely different structure than you asked for.</p>
<p><strong>Tool calling</strong> is the mechanism I reach for most often. You describe a function with a JSON Schema for its parameters, then force the model to call it. The model returns arguments matching that schema rather than free text. Support is broad, the schema travels with the request so you're not duplicating it in the prompt, and providers tend to constrain the output more aggressively than they do in plain JSON mode.</p>
<p>Here's what that looks like with the Anthropic SDK:</p>
<pre><code class="language-ts">const response = await client.messages.create({
  model: "claude-sonnet-5",
  max_tokens: 4096,
  tools: [
    {
      name: "emit_resume",
      description: "Return the parsed resume as structured data.",
      input_schema: jsonSchema,
    },
  ],
  tool_choice: { type: "tool", name: "emit_resume" },
  messages: [{ role: "user", content: resumeText }],
});

const block = response.content.find((b) =&gt; b.type === "tool_use");
const candidate = block?.input;
</code></pre>
<p>The <code>tool_choice</code> field is the important part. Without it the model decides whether to call the tool, and sometimes it will just answer in prose instead. Forcing the specific tool removes that branch entirely.</p>
<p><strong>Constrained decoding</strong> is the strongest option and the least widely available. Instead of asking the model to follow a schema, the runtime masks the token sampler at every step so that tokens which would produce invalid output can't be selected at all. Invalid output becomes impossible rather than unlikely.</p>
<p>OpenAI exposes a version of this through strict structured outputs, and if you run models locally you can use GBNF grammars in llama.cpp or a library such as Outlines.</p>
<p>The tradeoff is that constrained decoding can push the model into awkward corners. If the schema demands a field the document genuinely doesn't contain, the model can't decline, so it fills the slot with something. You've traded a parsing failure for a hallucination, which is harder to detect. I make required fields nullable for exactly this reason, which I'll come back to shortly.</p>
<p>My default is tool calling with a nullable-heavy schema, plus validation on top. That combination gives most of the benefit without the issues.</p>
<h2 id="heading-start-with-the-schema-not-the-prompt">Start with the Schema, Not the Prompt</h2>
<p>The instinct when output quality is poor is to write a longer prompt. More examples, more emphasis, and more capital letters. This helps a little and scales badly, because the prompt is prose and prose isn't enforceable.</p>
<p>A better approach is to treat the schema as the primary artifact and let everything else derive from it. Define it once, and use that single definition to generate the TypeScript type, the JSON Schema sent to the API, and the runtime validator. When the shape changes, all three change together, and there's no way for them to drift apart.</p>
<p>With Zod that looks like this:</p>
<pre><code class="language-ts">import { z } from "zod";

const YearMonth = z
  .string()
  .regex(/^\d{4}-\d{2}$/, "Expected a YYYY-MM date");

const Role = z.object({
  company: z.string().min(1),
  title: z.string().min(1),
  startDate: YearMonth,
  endDate: YearMonth.nullable(),
  bullets: z.array(z.string().min(1)).min(1).max(5),
});

const Resume = z.object({
  name: z.string().min(1),
  email: z.string().email().nullable(),
  roles: z.array(Role),
});

export type Resume = z.infer&lt;typeof Resume&gt;;
</code></pre>
<p>Three details in there are doing real work.</p>
<p>The <code>YearMonth</code> regex is narrower than <code>z.string()</code>, and that narrowness is the point. A bare string field invites the model to return <code>"January 2019"</code> or <code>"2019 - Present"</code> or <code>"01/2019"</code>, and all of those pass validation. Constraining the format at the schema level means the mismatch surfaces immediately instead of three layers deeper in your date-handling code.</p>
<p>The <code>endDate</code> field is nullable rather than optional. This is the single change that most improved extraction quality for me. An optional field lets the model quietly omit it, and you can't distinguish "the document did not say" from "the model forgot." A nullable field forces an explicit decision, and <code>null</code> is a meaningful answer that means the role is current.</p>
<p>The <code>max(5)</code> on bullets encodes a product constraint directly into the contract rather than trimming the array afterward. If the model exceeds it, you want to know, because it usually means the model is padding rather than extracting.</p>
<p>To send the schema to the API, derive the JSON Schema from the same definition:</p>
<pre><code class="language-ts">import { zodToJsonSchema } from "zod-to-json-schema";

const jsonSchema = zodToJsonSchema(Resume, { target: "openApi3" });
</code></pre>
<p>One habit worth adopting: write a <code>.describe()</code> on any field where the name alone is ambiguous. Those descriptions end up in the JSON Schema, which means they reach the model as part of the tool definition, which means they function as targeted, structured prompt instructions attached to exactly the field they concern.</p>
<pre><code class="language-ts">const Role = z.object({
  company: z.string().min(1),
  title: z.string().min(1).describe("The person's job title, not the team name"),
  startDate: YearMonth,
  endDate: YearMonth.nullable().describe("null if this role is current"),
  bullets: z
    .array(z.string().min(1))
    .min(1)
    .max(5)
    .describe("Verbatim from the document. Do not rewrite or summarise."),
});
</code></pre>
<p>That last description eliminated an entire class of failure for me, where the model would helpfully improve the wording of a person's own bullet points.</p>
<h2 id="heading-validation-is-two-jobs-not-one">Validation Is Two Jobs, Not One</h2>
<p>Once the response comes back, it's tempting to run <code>schema.parse()</code> and consider the job done. Zod will tell you whether the shape is right, and if it is, you move on.</p>
<p>But shape validation and semantic validation are different jobs, and only the first one is free. Zod can tell you that <code>endDate</code> is a string matching <code>YYYY-MM</code>. It can't tell you that the end date falls before the start date, or that the date is in the future, or that a role listed as current also has an end date. Those are all schema-valid and all wrong.</p>
<p>So run two passes. The first is structural and comes from the schema. The second is a plain function that encodes what you know about the domain:</p>
<pre><code class="language-ts">function findSemanticProblems(resume: Resume): string[] {
  const problems: string[] = [];
  const currentMonth = new Date().toISOString().slice(0, 7);

  for (const role of resume.roles) {
    if (role.startDate &gt; currentMonth) {
      problems.push(`${role.company}: start date is in the future`);
    }
    if (role.endDate &amp;&amp; role.endDate &lt; role.startDate) {
      problems.push(`${role.company}: end date precedes start date`);
    }
  }

  const currentRoles = resume.roles.filter((r) =&gt; r.endDate === null);
  if (currentRoles.length &gt; 1) {
    problems.push("More than one role is marked as current");
  }

  return problems;
}
</code></pre>
<p>None of this is clever code, and that's rather the point. It's ordinary business logic that happens to be checking a model's work rather than a user's. Write it as you discover failures, and treat each new check as a permanent regression test for a mistake the model made once.</p>
<p>There's a useful asymmetry here. Semantic problems are often better surfaced to the user than retried, because the model frequently can't do better with the information available. If a document genuinely lists two current roles, that's what the document says, and asking the model again won't change it.</p>
<h2 id="heading-building-a-retry-loop-that-doesnt-burn-tokens">Building a Retry Loop That Doesn't Burn Tokens</h2>
<p>When validation fails, the naïve fix is to call the model again with the same prompt and hope for a different sample. That works often enough to feel reasonable and is wasteful enough to notice on the bill, because you're paying full input cost for a request that has already mostly succeeded.</p>
<p>Two changes make a large difference.</p>
<p>The first is to attempt a local repair before spending anything. A meaningful share of failures are cosmetic, and you can fix them with string handling:</p>
<pre><code class="language-ts">function salvage(raw: string): string {
  let text = raw.trim();

  // Strip a Markdown fence the model added despite instructions.
  const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
  if (fenced) text = fenced[1];

  // Drop any prose before the first brace or after the last one.
  const start = text.indexOf("{");
  const end = text.lastIndexOf("}");
  if (start !== -1 &amp;&amp; end &gt; start) text = text.slice(start, end + 1);

  return text;
}
</code></pre>
<p>The second is that when you do call the model again, you should send a repair request rather than a fresh attempt. Include the original prompt, the output that failed, and the specific validation errors. The model has already done the hard extraction work, and you're asking it to fix a small number of named problems rather than start over. Repairs converge faster and produce shorter outputs, which means they cost less.</p>
<pre><code class="language-ts">async function requestRepair(
  originalPrompt: string,
  badOutput: string,
  error: z.ZodError,
): Promise&lt;string&gt; {
  const issues = error.issues
    .map((issue) =&gt; `${issue.path.join(".") || "root"}: ${issue.message}`)
    .join("\n");

  const response = await client.messages.create({
    model: "claude-sonnet-5",
    max_tokens: 4096,
    messages: [
      { role: "user", content: originalPrompt },
      { role: "assistant", content: badOutput },
      {
        role: "user",
        content:
          `That output failed validation with these problems:\n${issues}\n\n` +
          `Return the corrected JSON only. Keep everything that was already correct.`,
      },
    ],
  });

  return response.content[0].type === "text" ? response.content[0].text : "";
}
</code></pre>
<p>Putting it together, the full loop separates the failure classes and caps the spend:</p>
<pre><code class="language-ts">type Outcome&lt;T&gt; =
  | { ok: true; value: T; attempts: number }
  | { ok: false; error: string; attempts: number };

async function extract&lt;T&gt;(
  schema: z.ZodType&lt;T&gt;,
  prompt: string,
  maxAttempts = 3,
): Promise&lt;Outcome&lt;T&gt;&gt; {
  let lastRaw = "";
  let lastError: z.ZodError | null = null;

  for (let attempt = 1; attempt &lt;= maxAttempts; attempt++) {
    lastRaw =
      attempt === 1
        ? await callModel(prompt)
        : await requestRepair(prompt, lastRaw, lastError!);

    let candidate: unknown;
    try {
      candidate = JSON.parse(salvage(lastRaw));
    } catch {
      continue; // Syntax failure. Try again without a schema error to report.
    }

    const result = schema.safeParse(candidate);
    if (result.success) {
      return { ok: true, value: result.data, attempts: attempt };
    }
    lastError = result.error;
  }

  return {
    ok: false,
    error: lastError?.message ?? "Output was never parseable",
    attempts: maxAttempts,
  };
}
</code></pre>
<p>Two things about this loop are deliberate. It returns the attempt count, which you should log, because attempts per success is the single most useful health metric for an extraction pipeline. And it caps attempts at three. If three tries haven't produced valid output, a fourth rarely helps, and by then you're better off degrading gracefully than continuing to pay.</p>
<p>One more distinction worth respecting: a rate limit error and a schema validation error aren't the same failure and shouldn't share a retry policy. Rate limits want exponential backoff, because the problem is timing. Schema failures want an immediate repair request, because the problem is content, and waiting changes nothing.</p>
<h2 id="heading-streaming-structured-output">Streaming Structured Output</h2>
<p>Streaming and structured output pull against each other. Streaming exists so the user sees progress before the response finishes, but you can't parse a JSON object until the closing brace arrives. If your extraction takes twelve seconds, the best options are to show a spinner for twelve seconds or find a way to stream something meaningful.</p>
<p>There are two reasonable approaches.</p>
<p>The first is a partial JSON parser, which takes an incomplete string and returns the largest valid structure it can infer, closing open braces and dropping the trailing incomplete value. Libraries such as <code>best-effort-json-parser</code> do this. It works, and it's the right choice when the output is genuinely one large object. The cost is that intermediate states can be misleading, because a field can appear with a truncated value that looks complete.</p>
<p>The second approach, which I prefer when the output is a list, is to change the output format so that streaming is natural. Instead of asking for one array of objects, ask for one object per line. Each line is independently parseable, so you can validate and emit items the moment they complete:</p>
<pre><code class="language-ts">const stream = client.messages.stream({
  model: "claude-sonnet-5",
  max_tokens: 4096,
  messages: [{ role: "user", content: prompt }],
});

let buffer = "";

for await (const event of stream) {
  if (event.type !== "content_block_delta") continue;
  buffer += event.delta.text ?? "";

  const lines = buffer.split("\n");
  buffer = lines.pop() ?? ""; // Keep the incomplete tail for the next chunk.

  for (const line of lines) {
    if (!line.trim()) continue;

    try {
      const parsed = Role.safeParse(JSON.parse(line));
      if (parsed.success) onRole(parsed.data);
    } catch {
      // A malformed line is dropped rather than failing the whole stream.
    }
  }
}
</code></pre>
<p>The buffer handling is the part that people get wrong. Network chunks don't align with line boundaries, so a chunk will frequently end mid-object. Popping the final element back into the buffer and carrying it forward is what makes the loop correct.</p>
<p>This pattern gives you per-item validation for free, which is a real advantage over parsing one large object. One bad item doesn't invalidate the rest of the extraction.</p>
<h2 id="heading-the-failures-you-cant-retry-away">The Failures You Can't Retry Away</h2>
<p>Everything so far assumes the model can produce the right answer if you ask correctly. Some failures don't work that way, and treating them as retry candidates wastes money while producing confidently wrong data.</p>
<p>The most important of these is extraction from something the source doesn't contain. If a résumé genuinely has no email address and your schema requires one, the model will produce something plausible.</p>
<p>Retrying produces a different plausible thing. This is the failure mode that constrained decoding makes worse rather than better, because the grammar guarantees a well-formed value in a slot that should've been empty. The fix is at the schema level, which is why nearly every field in my extraction schemas is nullable.</p>
<p>Ambiguity in the source is a similar case. When a document lists a date range next to two different job titles, there's no correct extraction, only a guess. Retrying gives you a different guess with the same confidence. These situations want a confidence signal in the schema and a review step in the interface, not another API call.</p>
<p>Then there's silent truncation. If the response hits the output token limit mid-object, you get syntactically broken JSON, and a naïve retry loop treats it as a transient parse failure and tries again with the same limit, failing identically each time. Check the stop reason on the response. If the model stopped because it ran out of tokens, retrying without raising the limit or splitting the input is guaranteed to fail again.</p>
<p>The general principle is that your retry policy should ask what kind of failure this is before deciding whether repeating the call could possibly help.</p>
<h2 id="heading-what-this-actually-costs">What This Actually Costs</h2>
<p>Every layer above has a price, and it's worth measuring rather than assuming.</p>
<p>The main figure to track is <strong>attempts per successful extraction</strong>. Log it on every request and watch the p95 rather than the mean, because the mean hides the tail where the money goes. When a schema change causes a quality regression, this number moves before anything else does.</p>
<p>Prompt caching matters more here than in most workloads. Extraction prompts are unusually cache-friendly because the system prompt, the schema, and any few-shot examples are byte-identical on every request, and only the document changes.</p>
<p>On a pipeline where the schema and instructions ran to a couple of thousand tokens, moving that block into a cached prefix cut input cost substantially, and the saving compounds with every retry because repairs re-send the same prefix.</p>
<p>Repair requests are cheaper than fresh attempts for a reason worth understanding. The input grows, since you're now sending the original prompt plus the failed output plus the error list. But the output shrinks considerably, because the model is correcting a handful of fields rather than generating the full document again. Output tokens are the expensive direction on most providers, so the trade is usually favourable.</p>
<p>Two other things are worth instrumenting from the start: the distribution of validation error paths, which tells you exactly which schema fields are causing trouble and is far more actionable than an overall failure rate, and your local-repair hit rate, since if <code>salvage()</code> is fixing a large fraction of responses you have a prompt problem you can solve once rather than paying for repeatedly.</p>
<h2 id="heading-when-you-dont-need-any-of-this">When You Don't Need Any of This</h2>
<p>This machinery earns its place in a specific set of circumstances, and it's genuinely overkill outside them.</p>
<p>If the model's output goes straight to a human who will read it as prose, you don't need structured output at all. A chat interface, a summary, or a draft email all have a person as the validator, and adding a schema in front of that just constrains the model for no benefit.</p>
<p>If you're extracting one or two fields rather than a document's worth, a well-targeted prompt with a light <code>safeParse</code> and a single retry will serve you fine. The retry loop, semantic validation layer, and streaming parser are answers to problems that appear at scale and with schema complexity.</p>
<p>If your volume is low and a human reviews every result anyway, the validation layer is duplicating work someone is already doing. Surface the raw output, let the reviewer correct it, and spend the engineering time elsewhere.</p>
<p>And if you're still exploring what the feature should be, resist building this early. The schema is the most expensive thing to change once extraction code, validation rules, and stored data all depend on it. Prototype loosely, learn what fields you actually need, and tighten afterwards.</p>
<p>The safest rule is to start with a schema and <code>safeParse</code>, and add each subsequent layer only when a real failure justifies it. Every technique in this article came from a specific production incident rather than from a design document.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The gap between a working demo and a reliable feature is almost entirely in this layer. The model isn't the hard part any more, and the prompt isn't usually the hard part either. The hard part is deciding what you'll accept, detecting when you didn't get it, and responding sensibly when that happens.</p>
<p>Three ideas carry most of the weight:</p>
<ol>
<li><p><strong>The schema is the contract, and everything derives from it.</strong> One definition producing your type, your API schema, and your validator means those three can never drift apart.</p>
</li>
<li><p><strong>Separate syntax failures, schema failures, and semantic failures.</strong> They have different causes and different fixes, and a retry loop that treats them identically will waste money on problems that repeating the call can't solve.</p>
</li>
<li><p><strong>Make fields nullable rather than optional.</strong> Forcing the model to say "this was not present" rather than allowing it to quietly omit the field converts a whole class of silent hallucination into an explicit, checkable value.</p>
</li>
</ol>
<p>Get those right and the rest is ordinary engineering. You're writing validation code and retry logic, which developers have been doing against unreliable inputs for decades. A language model is just a new kind of unreliable input, and it responds well to the same discipline.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://zod.dev">Zod</a>: the schema library used throughout these examples.</p>
</li>
<li><p><a href="https://github.com/StefanTerdell/zod-to-json-schema">zod-to-json-schema</a>: converts a Zod schema into the JSON Schema an API expects.</p>
</li>
<li><p><a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview">Anthropic tool use documentation</a>: forcing structured arguments through a tool definition.</p>
</li>
<li><p><a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI structured outputs</a>: strict schema conformance through constrained decoding.</p>
</li>
<li><p><a href="https://github.com/dottxt-ai/outlines">Outlines</a>: grammar-constrained generation for locally hosted models.</p>
</li>
<li><p><a href="https://github.com/beenotung/best-effort-json-parser">best-effort-json-parser</a>: parsing incomplete JSON while a response is still streaming.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ High-Frequency Real-Time Data in React: From Ring Buffers to OffscreenCanvas ]]>
                </title>
                <description>
                    <![CDATA[ React is great at many things. But if you've ever tried pushing thousands of data points per second through it, you'll quickly learn that React isn't a firehose. It's more like a garden hose. Try forc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/high-frequency-real-time-data-in-react-from-ring-buffers-to-offscreencanvas/</link>
                <guid isPermaLink="false">6a84d268fa401f6597cd200e</guid>
                
                    <category>
                        <![CDATA[ react js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ realtime ]]>
                    </category>
                
                    <category>
                        <![CDATA[ workers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ concurrency ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Tue, 18 Aug 2026 21:45:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c3ffaae8-51d5-4add-9a9e-e49443e49746.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>React is great at many things. But if you've ever tried pushing thousands of data points per second through it, you'll quickly learn that React isn't a firehose. It's more like a garden hose.</p>
<p>Try forcing too much through it, and either the lawn floods (your DOM) or the pipe bursts (your app).</p>
<p>There's a second observation that pairs with the first. Your laptop has 8 to 16 CPU cores. Your React app uses 1 of them, almost always. The main thread handles JavaScript, the DOM, layout, and paint setup. The other cores sit idle while the main thread struggles to keep a 60fps frame budget.</p>
<p>Both problems have the same shape: you need to keep React out of the hot path, and you need to use more than one thread. The patterns that get you there also happen to be the patterns behind Figma's canvas engine, Bloomberg's trading dashboards, and every biosignal viewer you've seen.</p>
<p>In one project, I had to visualise 19 EEG (brainwave) channels, each sending about 1,000 data points per second. That's almost 19,000 updates per second. If you feed all of that directly into React, the UI doesn't just slow down. It faints dramatically.</p>
<p>This article is the end-to-end architecture I landed on: the ring buffers, workers, shared memory, off-main rendering, and specific patterns that hold up under sustained multi-hour load.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-whos-already-doing-this">Who's Already Doing This?</a></p>
</li>
<li><p><a href="#heading-the-1khz-math">The 1kHz Math</a></p>
</li>
<li><p><a href="#heading-where-it-usually-goes-wrong">Where it Usually Goes Wrong</a></p>
</li>
<li><p><a href="#heading-the-mental-model-air-traffic-control-plus-a-kitchen-brigade">The Mental Model: Air Traffic Control Plus a Kitchen Brigade</a></p>
</li>
<li><p><a href="#heading-step-1-stop-putting-samples-in-react-state">Step 1: Stop Putting Samples in React State</a></p>
</li>
<li><p><a href="#heading-step-2-separate-shape-from-values">Step 2: Separate Shape from Values</a></p>
</li>
<li><p><a href="#heading-step-3-move-heavy-work-off-the-main-thread">Step 3: Move Heavy Work Off the Main Thread</a></p>
</li>
<li><p><a href="#heading-step-4-render-off-main-with-offscreencanvas">Step 4: Render Off Main with OffscreenCanvas</a></p>
</li>
<li><p><a href="#heading-step-5-decimate-before-you-draw">Step 5: Decimate Before You Draw</a></p>
</li>
<li><p><a href="#heading-step-6-wrap-an-external-renderer">Step 6: Wrap an External Renderer</a></p>
</li>
<li><p><a href="#heading-step-7-when-canvas-isnt-enough-reach-for-webgl">Step 7: When Canvas isn't Enough, Reach for WebGL</a></p>
</li>
<li><p><a href="#heading-step-8-keep-memory-flat">Step 8: Keep Memory Flat</a></p>
</li>
<li><p><a href="#heading-step-9-scheduling-strategies">Step 9: Scheduling Strategies</a></p>
</li>
<li><p><a href="#heading-step-10-measure-sustained-performance">Step 10: Measure Sustained Performance</a></p>
</li>
<li><p><a href="#heading-case-study-19-eeg-channels">Case Study: 19 EEG Channels</a></p>
</li>
<li><p><a href="#heading-benchmarks-single-thread-vs-multi-thread">Benchmarks: Single Thread vs Multi-Thread</a></p>
</li>
<li><p><a href="#heading-the-coopcoep-catch">The COOP/COEP Catch</a></p>
</li>
<li><p><a href="#heading-production-tradeoffs">Production Tradeoffs</a></p>
</li>
<li><p><a href="#heading-should-you-build-like-this">Should You Build Like This?</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this article, you'll want:</p>
<ul>
<li><p><strong>Working knowledge of React 18 or 19.</strong> You should be comfortable with <code>useState</code>, <code>useEffect</code>, <code>useRef</code>, and the difference between mounting and re-rendering.</p>
</li>
<li><p><strong>TypeScript basics.</strong> Most examples are in TypeScript. You should be able to read type annotations without stopping.</p>
</li>
<li><p><strong>A rough sense of the browser main thread and event loop.</strong> You don't need to have written a Web Worker, but knowing what "blocking the main thread" means will make Step 3 easier.</p>
</li>
<li><p><strong>Familiarity with Canvas 2D or a chart library</strong> is a plus, not a requirement. If you've drawn anything on a canvas, you're ready.</p>
</li>
<li><p><strong>A laptop that can run modern Chrome or Edge.</strong> The examples rely on <code>SharedArrayBuffer</code>, <code>OffscreenCanvas</code>, and Atomics, which need a Chromium-based browser and cross-origin isolation (covered later in the article).</p>
</li>
</ul>
<p>You don't need prior experience with Web Workers, ring buffers, or WebGL. This article introduces each in the context of a real problem.</p>
<h2 id="heading-whos-already-doing-this">Who's Already Doing This?</h2>
<p>The patterns in this article aren't experimental. They're the architecture behind production apps that ingest and render high-frequency data:</p>
<ul>
<li><p><strong>Trading and finance dashboards</strong> (Bloomberg, Hyperliquid, dYdX, every serious market viewer) push thousands of price ticks per second through canvas-rendered grids.</p>
</li>
<li><p><strong>Figma</strong> runs its entire canvas engine in WebAssembly inside a worker. The main thread renders React for the chrome only.</p>
</li>
<li><p><strong>Google Docs and Microsoft Loop</strong> run their document models in workers, with the DOM as the projection.</p>
</li>
<li><p><strong>Charting libraries</strong> like LightningChart, uPlot, Plotly, and ECharts draw on Canvas or WebGL and treat React as a wrapper.</p>
</li>
<li><p><strong>Biosignal, ECG, EEG, and motion-capture apps</strong> routinely process samples at 1kHz or higher and stream them to live plots.</p>
</li>
<li><p><strong>Observability and APM tools</strong> (Datadog live tail, Grafana real-time panels) decouple ingestion from render to keep tabs responsive.</p>
</li>
<li><p><strong>Audio editors and visualisers</strong>, plus anything using the Web Audio API with a waveform display.</p>
</li>
<li><p><strong>transformers.js and ONNX Runtime Web</strong> place ML inference in workers by default.</p>
</li>
</ul>
<p>Different domains, same trick: React owns what changes rarely, something else owns what changes at refresh rate, and heavy work happens on threads that aren't the main one.</p>
<h2 id="heading-the-1khz-math">The 1kHz Math</h2>
<p>Some numbers to make the problem concrete.</p>
<ul>
<li><p>A sample arrives every 1ms.</p>
</li>
<li><p>A 60Hz display refreshes every 16.67ms.</p>
</li>
<li><p>So in one frame, you'll receive roughly <strong>16 to 17 samples per stream</strong>.</p>
</li>
<li><p>With 19 active streams (the EEG case), that's <strong>300 to 320 samples per frame</strong>.</p>
</li>
</ul>
<p>If you <code>setState</code> on each sample, React tries to do around 19,000 renders per second. It can't, so it skips frames. The UI stutters and your laptop fans take off.</p>
<p>If you <code>setState</code> once per frame with the batch of ~320 samples, React does 60 renders per second, which is easy.</p>
<p>That single reframe is the entire trick, and it will echo through every step below.</p>
<h2 id="heading-where-it-usually-goes-wrong">Where it Usually Goes Wrong</h2>
<p>Here's the version of the code I see in most real-time React apps the first time they try this. It looks reasonable. It's also the source of every bit of jank the team will spend the next two weeks tracking down.</p>
<pre><code class="language-tsx">import { useEffect, useState } from "react";

export default function NaiveChart({ socket }) {
  const [data, setData] = useState&lt;number[]&gt;([]);

  useEffect(() =&gt; {
    socket.on("newPoint", (point: number) =&gt; {
      setData((prev) =&gt; [...prev, point]); // re-renders every time
    });
  }, [socket]);

  return &lt;div&gt;{data.length} points&lt;/div&gt;;
}
</code></pre>
<p>Three problems baked into seven lines:</p>
<ol>
<li><p><strong>Every incoming sample triggers a re-render:</strong> At 1kHz that's 1,000 renders per second. React was never going to be happy about that.</p>
</li>
<li><p><code>[...prev, point]</code> <strong>allocates a new array on every push:</strong> At 1kHz that's a new array per millisecond, all of which the garbage collector has to clean up. The heap climbs, GC pauses lengthen, the fan kicks in.</p>
</li>
<li><p><strong>There's no upper bound on the array:</strong> Run this for an hour and you have 3.6 million numbers in memory, all of which React has to consider on every render.</p>
</li>
</ol>
<p>The fix isn't one trick. It's a stack of small ones, each addressing one of those failure modes and, eventually, the deeper problem of the main thread being the only thread.</p>
<h2 id="heading-the-mental-model-air-traffic-control-plus-a-kitchen-brigade">The Mental Model: Air Traffic Control Plus a Kitchen Brigade</h2>
<p>Before the code, two metaphors that will keep the pieces straight.</p>
<p><strong>First, air traffic control:</strong> Three roles, one airport.</p>
<ul>
<li><p>The <strong>control tower</strong> (your store) sees every plane (every sample) and tracks where it is.</p>
</li>
<li><p>The <strong>ground crew</strong> (React) sets up the runways and gates: the layout that planes use.</p>
</li>
<li><p>The <strong>pilots</strong> (the draw loop) actually fly. They look at the tower for clearance and act every few seconds.</p>
</li>
</ul>
<p>The control tower doesn't pull a gate from the ground crew every time a plane moves. It just holds the data. The ground crew rearranges gates when the schedule changes, which is rare. The pilots act constantly, at their own rate, off the tower's data.</p>
<p><strong>Second, a kitchen brigade:</strong> A restaurant kitchen at peak hour. One head chef can't make every dish. The classical brigade has stations: sauce, fish, pastry, garde manger, plating, and service. Each station owns a slice of the meal. The expediter coordinates timing.</p>
<ul>
<li><p>The <strong>expediter</strong> is your main thread.</p>
</li>
<li><p>The <strong>stations</strong> are your workers.</p>
</li>
<li><p>The <strong>plating window</strong> is your shared memory.</p>
</li>
<li><p>The <strong>dishes going to tables</strong> are your rendered frames.</p>
</li>
</ul>
<p>A single chef trying to do everything serially is your main-thread-only frontend. A brigade is your worker-based one. The brigade is faster because cuts, sauces, and sears happen in parallel, not because any one cook is faster than the soloist. The expediter doesn't cook, they orchestrate.</p>
<p>Everything below is a specific application of these two ideas.</p>
<h2 id="heading-step-1-stop-putting-samples-in-react-state">Step 1: Stop Putting Samples in React State</h2>
<p>The single biggest mistake in real-time React apps is treating every sample as state, which it isn't. State is what determines <em>which components exist and how they're arranged</em>. A live plot is one component. The 60,000 samples scrolling across it aren't 60,000 pieces of state. They're one buffer.</p>
<p>You want a store that lives outside React. A ring buffer over a typed array gives you constant-time inserts and a bounded heap:</p>
<pre><code class="language-ts">type Listener = () =&gt; void;

export function createSampleStore(capacity: number) {
  const buf = new Float32Array(capacity);
  let head = 0;
  let size = 0;
  const listeners = new Set&lt;Listener&gt;();

  return {
    push(sample: number) {
      buf[head] = sample;
      head = (head + 1) % capacity;
      if (size &lt; capacity) size++;
    },
    pushBatch(samples: Float32Array) {
      for (let i = 0; i &lt; samples.length; i++) {
        buf[head] = samples[i];
        head = (head + 1) % capacity;
        if (size &lt; capacity) size++;
      }
    },
    read(): Float32Array {
      if (size &lt; capacity) return buf.subarray(0, size);
      const out = new Float32Array(capacity);
      out.set(buf.subarray(head));
      out.set(buf.subarray(0, head), capacity - head);
      return out;
    },
    subscribe(l: Listener) {
      listeners.add(l);
      return () =&gt; listeners.delete(l);
    },
  };
}
</code></pre>
<p>Notice what's not here: no <code>useState</code>, no setter, no React anything. It's just plain JavaScript. The store can accept a million pushes per second and React won't care, because React isn't subscribed.</p>
<p>Here are the ring buffer mechanics, visualised:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779361162753/2374fc6e-429c-4a8a-8b31-30fb51c6052c.png" alt="Ring buffer with eight indexed slots, a head pointer advancing each push and wrapping around when it reaches the end, providing constant-time inserts and a bounded heap" style="display: block;" width="1600" height="463" loading="lazy">

<p>We have head advances on every push and wraps at capacity. Reads pull a window of the last N samples in chronological order. The result is constant time and a bounded heap.</p>
<p>A simpler stop-gap, when you don't want a typed buffer yet, is to put the rolling window in a ref instead of state:</p>
<pre><code class="language-tsx">import { useEffect, useRef } from "react";

export default function RefChart({ socket }) {
  const bufferRef = useRef&lt;number[]&gt;([]);

  useEffect(() =&gt; {
    socket.on("newPoint", (point: number) =&gt; {
      bufferRef.current.push(point);
      if (bufferRef.current.length &gt; 1000) {
        bufferRef.current.shift();
      }
    });
  }, [socket]);

  return &lt;div&gt;Streaming {bufferRef.current.length} points&lt;/div&gt;;
}
</code></pre>
<p>This is the "10x faster than <code>NaiveChart</code>, still not great" version. The render doesn't trigger on every push, but you also won't see updates unless something else re-renders. For real plotting, pair the ref with a <code>requestAnimationFrame</code> draw loop (see Step 2).</p>
<h2 id="heading-step-2-separate-shape-from-values">Step 2: Separate Shape from Values</h2>
<p>React renders when the <em>shape</em> of the UI changes. New plot? Re-render. Removed plot? Re-render. Switched from line to bar? Re-render. None of those happen at 1000Hz. They happen a few times a minute, when the user clicks something.</p>
<p>The <em>values</em> inside each plot change at the data rate. Those should never touch React.</p>
<p>Concretely:</p>
<pre><code class="language-tsx">function LivePlot({ store }: { store: SampleStore }) {
  const canvasRef = useRef&lt;HTMLCanvasElement&gt;(null);

  useEffect(() =&gt; {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;

    let raf = 0;
    const draw = () =&gt; {
      const samples = store.read();
      drawSeries(ctx, samples);
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);
    return () =&gt; cancelAnimationFrame(raf);
  }, [store]);

  return &lt;canvas ref={canvasRef} width={1200} height={300} /&gt;;
}
</code></pre>
<p>React renders this component once. The <code>useEffect</code> runs once. The <code>requestAnimationFrame</code> loop reads from the store on every frame.</p>
<p>The store can be pushed to at any rate the source can manage. The user sees a smooth 60fps line whether the source is sending 100 samples per second or 100,000. The instinct to wire <code>samples</code> into <code>useState</code> is wrong, so resist it.</p>
<h3 id="heading-selective-subscriptions-when-you-do-need-react-in-the-loop">Selective Subscriptions When You Do Need React in the Loop</h3>
<p>Sometimes a component genuinely depends on the data (summary stats, axis labels, or a live value badge). For that, use a store with selective subscriptions so only the components that care re-render. Zustand makes this trivial:</p>
<pre><code class="language-ts">import { create } from "zustand";

const useDataStore = create&lt;{ latest: number | null; setLatest: (v: number) =&gt; void }&gt;((set) =&gt; ({
  latest: null,
  setLatest: (v) =&gt; set({ latest: v }),
}));

function LatestBadge() {
  // Only re-renders when latest changes, not when other store fields do.
  const latest = useDataStore((state) =&gt; state.latest);
  return &lt;div&gt;Latest: {latest?.toFixed(2)}&lt;/div&gt;;
}
</code></pre>
<p>Pair this with RAF coalescing on the writes (call <code>setLatest</code> once per frame, not once per sample) and you get a React component that updates smoothly at 60fps no matter what the data rate is.</p>
<p><code>useSyncExternalStore</code> is the native equivalent and works against any pub-sub store, including the ring buffer above. Use whichever feels lighter for your team.</p>
<h2 id="heading-step-3-move-heavy-work-off-the-main-thread">Step 3: Move Heavy Work Off the Main Thread</h2>
<p>The store and imperative draw loop handle React's contribution to the bottleneck. The main thread itself is still doing all the ingest, parsing, and math. Everything past ~50,000 samples per second per stream needs more.</p>
<p>The browser gives you four escape hatches: Web Workers, transferable objects, <code>SharedArrayBuffer</code> + Atomics, and <code>OffscreenCanvas</code>. Each solves a specific problem.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369113008/d1d608c9-8078-4339-91e1-c9ee221bf014.png" alt="Browser process model with the renderer process containing the main thread, dedicated workers, shared workers, a service worker, and a compositor thread; the GPU process containing the GPU thread for WebGL and WebGPU; and the network process containing the network thread for fetch and WebSocket, with communication between main and worker types happening via postMessage" style="display: block;" width="1600" height="1006" loading="lazy">

<p>The main thread runs JavaScript, the DOM, layout, and paint setup. Workers are isolated JavaScript contexts with their own event loops. They can't touch the DOM, they can't share memory by default, and every cross-thread message is async and copied unless you transfer it.</p>
<p>Three properties matter as you design against this model:</p>
<ul>
<li><p><strong>Workers can't touch the DOM:</strong> That's the point. They run pure JavaScript. Perfect for data work, network parsing, math, codecs, ML inference.</p>
</li>
<li><p><strong>Communication is async:</strong> No shared variable to read in the middle of a function. Plan APIs around requests and events.</p>
</li>
<li><p><strong>Data copies, unless you transfer or share it:</strong> Structured clone is the default, transferable objects skip the copy, and <code>SharedArrayBuffer</code> skips it permanently.</p>
</li>
</ul>
<h3 id="heading-dedicated-workers-in-practice">Dedicated Workers in Practice</h3>
<p>Here's a minimal worker for ingest:</p>
<pre><code class="language-ts">// worker.ts
self.onmessage = (event) =&gt; {
  const { samples } = event.data;
  // Decode, filter, decimate. The main thread doesn't see any of this.
  const summary = computeStats(samples);
  postMessage(summary);
};
</code></pre>
<pre><code class="language-tsx">// main thread
import { useEffect, useRef } from "react";

export default function WorkerChart({ socket, store }) {
  const workerRef = useRef&lt;Worker&gt;();

  useEffect(() =&gt; {
    workerRef.current = new Worker(new URL("./worker.ts", import.meta.url), {
      type: "module",
    });

    workerRef.current.onmessage = (event) =&gt; {
      store.pushBatch(event.data); // pre-processed, cheap to ingest
    };

    socket.on("newPoint", (point: number) =&gt; {
      workerRef.current?.postMessage({ point });
    });

    return () =&gt; workerRef.current?.terminate();
  }, [socket, store]);

  return &lt;LivePlot store={store} /&gt;;
}
</code></pre>
<p>Three things to internalise about workers.</p>
<p>First, workers are real processes from the runtime's perspective. Each has its own heap, event loop, and <code>globalThis</code>. Starting one costs about 1 to 5ms. Don't spin them up inside hot paths.</p>
<p>Second, module workers are the modern default. <code>type: "module"</code> enables ES modules inside the worker, including <code>import</code>. The legacy <code>importScripts</code> is for classic workers, so avoid it for new code.</p>
<p>Finally, bundlers know about workers. Vite, Webpack, esbuild, and Rspack all detect the <code>new Worker(new URL("./x.ts", import.meta.url))</code> pattern and produce a separate chunk for the worker.</p>
<h3 id="heading-a-worker-pool-that-scales-with-cores">A Worker Pool That Scales with Cores</h3>
<p>For CPU-bound work (parsing binary frames, decoding audio, computing FFTs), a pool spreads jobs across all available cores:</p>
<pre><code class="language-ts">class WorkerPool {
  private workers: Worker[];
  private next = 0;
  private pending = new Map&lt;string, (result: unknown) =&gt; void&gt;();

  constructor(scriptUrl: URL, size = Math.max(1, navigator.hardwareConcurrency - 1)) {
    this.workers = Array.from({ length: size }, () =&gt; {
      const w = new Worker(scriptUrl, { type: "module" });
      w.onmessage = (event) =&gt; {
        const cb = this.pending.get(event.data.id);
        if (!cb) return;
        this.pending.delete(event.data.id);
        cb(event.data.result);
      };
      return w;
    });
  }

  async run&lt;T&gt;(kind: string, payload: unknown, transferables: Transferable[] = []): Promise&lt;T&gt; {
    const id = crypto.randomUUID();
    const result = new Promise&lt;T&gt;((resolve) =&gt; this.pending.set(id, resolve as (r: unknown) =&gt; void));
    const worker = this.workers[this.next];
    this.next = (this.next + 1) % this.workers.length;
    worker.postMessage({ id, kind, payload }, transferables);
    return result;
  }
}

export const pool = new WorkerPool(new URL("./decoder.worker.ts", import.meta.url));
</code></pre>
<p>Round-robin distribution with one worker per core minus one. Pending requests resolve when the matching response arrives. It's cheap, predictable, and scales linearly until you hit memory bandwidth limits.</p>
<h3 id="heading-transferable-objects-the-no-copy-path">Transferable Objects, the No-copy Path</h3>
<p><code>postMessage</code> clones the payload by default. Cloning a 10MB buffer takes milliseconds and doubles your memory use. The fix is to <strong>transfer</strong> the buffer instead.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369124743/0a53c521-618c-48ec-a5c5-935e1acdbf1a.png" alt="Side-by-side comparison of postMessage modes: default postMessage clones the buffer with a copy landing in both the main heap and the worker heap via structured clone, while Transferable transfers ownership so the same buffer exists once with ownership moving from main to worker" style="display: block;" width="1600" height="4546" loading="lazy">

<p>The semantics: when you transfer a buffer, the sender loses access. The receiver gains it, with no copy and an O(1) handoff.</p>
<pre><code class="language-ts">const buf = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
new Uint8Array(buf).set(somePayload);

worker.postMessage({ buf }, [buf]); // second arg = list of transferables
// `buf` is now detached on this side. Accessing it throws.
</code></pre>
<p>The list of transferable types in 2026 includes <code>ArrayBuffer</code> (and any typed-array view backed by one), <code>MessagePort</code>, <code>ImageBitmap</code>, <code>OffscreenCanvas</code>, the stream types (<code>ReadableStream</code>, <code>WritableStream</code>, <code>TransformStream</code>), <code>RTCDataChannel</code>, <code>VideoFrame</code>, <code>AudioData</code>, and the WebTransport streams. The most useful for React plus real-time work are <code>ArrayBuffer</code>, <code>OffscreenCanvas</code>, and <code>MessagePort</code>.</p>
<p>A common pitfall is that forgetting to transfer creates silent slowness. The app works, but it copies every message. Profile worker <code>postMessage</code> calls. If they're showing milliseconds for "small" payloads, you're cloning when you should be transferring.</p>
<pre><code class="language-ts">// Bad: copies every frame.
worker.postMessage({ samples: float32Array });

// Good: transfers the underlying buffer.
worker.postMessage({ samples: float32Array }, [float32Array.buffer]);
</code></pre>
<p>The buffer is detached after transfer, so the sender needs to re-allocate (or pull from a pool of pre-allocated buffers) if it wants to keep producing.</p>
<h3 id="heading-sharedarraybuffer-and-atomics">SharedArrayBuffer and Atomics</h3>
<p>Transfer hands a buffer off. <strong>Sharing</strong> lets both threads see the same memory simultaneously.</p>
<pre><code class="language-ts">const sab = new SharedArrayBuffer(1024 * 1024); // 1 MB shared
worker.postMessage({ sab });

// Both main and worker now hold references to the same memory.
const viewMain = new Int32Array(sab);
// Inside worker:
// const viewWorker = new Int32Array(event.data.sab);
</code></pre>
<p>Three properties matter.</p>
<p><code>SharedArrayBuffer</code> needs cross-origin isolation. Your page must be served with <code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code>. Without these headers, <code>SharedArrayBuffer</code> is undefined in the browser. Covered in more detail later in the article.</p>
<p>You also need <code>Atomics</code> for synchronisation. Multiple threads writing to the same memory without coordination produces undefined results. <code>Atomics</code> gives you compare-and-swap, load, store, add, sub, wait, and notify operations on typed array views.</p>
<pre><code class="language-ts">const sab = new SharedArrayBuffer(8);
const view = new Int32Array(sab);

// Main thread: wake any worker that's waiting on slot 0.
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1);

// Worker: block until slot 0 changes from 0.
const result = Atomics.wait(view, 0, 0); // "ok", "not-equal", or "timed-out"
</code></pre>
<p>And lock-free ring buffers are the killer app. A producer-consumer queue between two threads with no locking, no <code>postMessage</code> round-trip, and no GC pressure. The data sits in the shared buffer. Atomics coordinate read/write positions.</p>
<p>Here's a minimal SPSC (single-producer, single-consumer) ring buffer:</p>
<pre><code class="language-ts">type SharedRing = {
  data: Float32Array;          // payload
  control: Int32Array;         // [head, tail]
};

function createSharedRing(capacity: number): SharedRing {
  const sab = new SharedArrayBuffer(capacity * 4 + 16);
  const control = new Int32Array(sab, 0, 4);   // [head, tail, ...]
  const data = new Float32Array(sab, 16, capacity);
  return { data, control };
}

function push(ring: SharedRing, value: number): boolean {
  const head = Atomics.load(ring.control, 0);
  const tail = Atomics.load(ring.control, 1);
  const next = (head + 1) % ring.data.length;
  if (next === tail) return false; // full
  ring.data[head] = value;
  Atomics.store(ring.control, 0, next);
  return true;
}

function pop(ring: SharedRing): number | null {
  const tail = Atomics.load(ring.control, 1);
  const head = Atomics.load(ring.control, 0);
  if (tail === head) return null; // empty
  const value = ring.data[tail];
  Atomics.store(ring.control, 1, (tail + 1) % ring.data.length);
  return value;
}
</code></pre>
<p>The producer writes from one thread. The consumer reads from another, neither blocks, and there's no <code>postMessage</code> between them. At signal rates, this is the only architecture that scales.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369132294/fcefe558-040e-41d3-b759-a8c10c5d5502.png" alt="Single-producer single-consumer ring buffer over SharedArrayBuffer: the producer (ingest worker) writes at the head pointer and increments it, the consumer (render thread) reads from the tail pointer and increments it, both pointers stored in the shared buffer and updated with atomic operations" style="display: block;" width="1600" height="308" loading="lazy">

<p>For multi-producer or multi-consumer queues, you need compare-and-swap loops (<code>Atomics.compareExchange</code>). They get fiddly fast. Most production setups use SPSC where they can and fall back to message-passing where they can't.</p>
<h3 id="heading-in-electron-ingest-in-the-main-process">In Electron, Ingest in the Main Process</h3>
<p>The same idea applies one level up. With Electron and a native SDK, you can ingest samples in the <strong>main process</strong>, buffer there, and forward batches across IPC at the renderer's refresh cadence. One IPC message per sample at 1kHz will saturate IPC. One IPC message per frame with a batch of 16 samples is trivial.</p>
<pre><code class="language-ts">// electron main: buffer in Node, flush at 60Hz
let pending: number[] = [];

device.on("sample", (value) =&gt; pending.push(value));

setInterval(() =&gt; {
  if (pending.length === 0) return;
  const batch = new Float32Array(pending);
  pending = [];
  // Transferable to avoid the structured-clone copy.
  mainWindow.webContents.send("device:samples", batch.buffer, [batch.buffer]);
}, 1000 / 60);
</code></pre>
<pre><code class="language-ts">// preload: expose a thin subscription API to the renderer
contextBridge.exposeInMainWorld("device", {
  onSamples: (cb: (samples: Float32Array) =&gt; void) =&gt; {
    const handler = (_: unknown, buf: ArrayBuffer) =&gt; cb(new Float32Array(buf));
    ipcRenderer.on("device:samples", handler);
    return () =&gt; ipcRenderer.removeListener("device:samples", handler);
  },
});
</code></pre>
<pre><code class="language-tsx">// renderer: feed the store from the bridge
useEffect(() =&gt; {
  return window.device.onSamples((batch) =&gt; store.pushBatch(batch));
}, []);
</code></pre>
<p>The renderer thread of the renderer touches only one batch per frame, no matter how fast the device is. The data work happens upstream.</p>
<h2 id="heading-step-4-render-off-main-with-offscreencanvas">Step 4: Render Off Main with <code>OffscreenCanvas</code></h2>
<p>The DOM is single-threaded. The Canvas API used to be too. <code>OffscreenCanvas</code> breaks that: a canvas you can transfer to a worker, where it draws independently of main.</p>
<pre><code class="language-ts">// Main thread
const canvas = canvasRef.current!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
</code></pre>
<pre><code class="language-ts">// Worker
let ctx: OffscreenCanvasRenderingContext2D | null = null;

self.onmessage = (event) =&gt; {
  if (event.data.canvas) {
    ctx = event.data.canvas.getContext("2d");
    return;
  }
  if (event.data.samples &amp;&amp; ctx) {
    drawSeries(ctx, event.data.samples);
  }
};
</code></pre>
<p>The main thread is now free to handle clicks, hover, and other interaction without competing with the draw loop. The worker draws at its own rate, against whatever data it has.</p>
<p>Combined with <code>SharedArrayBuffer</code>, you get the cleanest real-time rendering pipeline available in the browser:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369135331/1a205236-981d-4df7-8b21-698d15b84a63.png" alt="Rendering pipeline off the main thread: a data source feeds an ingest worker which writes into a SharedArrayBuffer, which a render worker owning an OffscreenCanvas reads to issue GPU commands that update the DOM, while the main thread reads a summary from the SharedArrayBuffer for React UI" style="display: block;" width="1600" height="260" loading="lazy">

<p>The ingest worker writes samples into the shared buffer. The render worker reads them and draws. Main only reads summaries (FPS, latest value, channel labels) and renders chrome. No data ever crosses through main's event loop.</p>
<p>For 19-channel signal at 1kHz, this is the only architecture that keeps a guaranteed 60fps on a laptop, with headroom.</p>
<h2 id="heading-step-5-decimate-before-you-draw">Step 5: Decimate Before You Draw</h2>
<p>A 1200-pixel-wide canvas can show, at best, 1200 distinct X positions. If you have 60,000 samples in your window and you draw all of them, you do 50 times more work than the user can see.</p>
<p>Pick the right point per pixel column. The "min-max" pattern works well for signals: for each pixel column, find the minimum and maximum value in that range and draw a vertical line between them. This is visually identical to drawing every point, but it's much cheaper.</p>
<pre><code class="language-ts">function drawDecimated(
  ctx: CanvasRenderingContext2D,
  samples: Float32Array,
  width: number,
) {
  const samplesPerPixel = samples.length / width;
  ctx.beginPath();
  for (let x = 0; x &lt; width; x++) {
    const start = Math.floor(x * samplesPerPixel);
    const end = Math.floor((x + 1) * samplesPerPixel);
    let min = Infinity;
    let max = -Infinity;
    for (let i = start; i &lt; end; i++) {
      const v = samples[i];
      if (v &lt; min) min = v;
      if (v &gt; max) max = v;
    }
    ctx.moveTo(x, scale(min));
    ctx.lineTo(x, scale(max));
  }
  ctx.stroke();
}
</code></pre>
<p><a href="https://en.wikipedia.org/wiki/Downsampling_(signal_processing)">Decimation</a> is the single biggest CPU win in real-time visualisation, and almost nobody does it.</p>
<p>A more visually faithful variant is <strong>LTTB (Largest Triangle Three Buckets)</strong>, an algorithm that picks one representative sample per bucket while preserving the visual shape better than min-max. It's worth the read if you're plotting non-signal data like stock charts where peaks and dips matter individually. Most good chart libraries (uPlot, Plotly, ECharts) include decimation out of the box.</p>
<h2 id="heading-step-6-wrap-an-external-renderer">Step 6: Wrap an External Renderer</h2>
<p>You don't always need to write the draw loop yourself. React is great at lifecycle management. Imperative chart libraries are great at raw performance. The right move is often to let React mount and unmount the chart while the library handles the fast inner loop.</p>
<pre><code class="language-tsx">import Uplot from "uplot";
import "uplot/dist/uPlot.min.css";
import { useEffect, useRef } from "react";

export default function UPlotChart({ data }: { data: AlignedData }) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);
  const plotRef = useRef&lt;Uplot | null&gt;(null);

  useEffect(() =&gt; {
    const opts = {
      title: "Realtime Chart",
      width: 600,
      height: 300,
      series: [{}, { label: "Signal" }],
    };
    plotRef.current = new Uplot(opts, data, ref.current!);
    return () =&gt; plotRef.current?.destroy();
  }, []);

  useEffect(() =&gt; {
    plotRef.current?.setData(data); // imperative update, no React render
  }, [data]);

  return &lt;div ref={ref} /&gt;;
}
</code></pre>
<p>uPlot, TimeChart, ECharts, Plotly, LightningChart, and the others all follow this shape: instantiate inside <code>useEffect</code>, call <code>setData</code> imperatively, and destroy on unmount. React orchestrates and the library renders.</p>
<p>The general pattern, for any imperative renderer, looks like this:</p>
<pre><code class="language-tsx">function ChartWrapper({ config, data }) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    const chart = new SomeFastChartLib(ref.current, config);
    chart.setData(data);
    return () =&gt; chart.destroy();
  }, [config, data]);

  return &lt;div ref={ref} /&gt;;
}
</code></pre>
<p>This is the highest-ROI move if you're not in a domain where you need pixel-level control over the drawing. Reach for a library first, and only write your own draw loop when no library fits.</p>
<h2 id="heading-step-7-when-canvas-isnt-enough-reach-for-webgl">Step 7: When Canvas isn't Enough, Reach for WebGL</h2>
<p>Canvas 2D handles a few thousand line segments per frame comfortably. Past that, you start spending milliseconds in <code>stroke()</code> itself.</p>
<p>WebGL (or its higher-level wrappers like <code>regl</code>, <code>twgl</code>, <code>pixi.js</code>, <code>deck.gl</code>) moves the rendering to the GPU. You pay an upfront cost (writing shaders and managing buffers) for the ability to draw millions of points without breaking a sweat.</p>
<p>Here's a minimal WebGL "line strip with a single vertex shader" sketch:</p>
<pre><code class="language-ts">const gl = canvas.getContext("webgl2")!;

const program = gl.createProgram()!;
// ... compile vertex + fragment shaders, link, get attribute location ...

const samplesBuffer = gl.createBuffer();
function drawWebGL(samples: Float32Array) {
  gl.bindBuffer(gl.ARRAY_BUFFER, samplesBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, samples, gl.STREAM_DRAW);
  gl.useProgram(program);
  gl.drawArrays(gl.LINE_STRIP, 0, samples.length);
}
</code></pre>
<p>The pattern is the same as Canvas 2D: an imperative draw call inside the RAF loop. The difference is the GPU does the actual rasterising. Most real-time charting libraries that claim "millions of points" are doing exactly this under the hood.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779361166770/22241c5c-446b-4026-88fb-33e3caad3831.png" alt="Decision tree comparing rendering options: Canvas 2D for up to a few thousand points, WebGL for tens of thousands or more, and WebGPU for 3D, custom shaders, or compute once stable" style="display: block;" width="1600" height="1351" loading="lazy">

<p>Default to Canvas 2D, and escalate to WebGL only when you can prove Canvas is the bottleneck on the Profiler.</p>
<h2 id="heading-step-8-keep-memory-flat">Step 8: Keep Memory Flat</h2>
<p>Real-time apps die slowly. They run smoothly for an hour and then the laptop fan kicks in. The cause is almost always memory.</p>
<p>There are three rules that keep the heap calm.</p>
<p>First, use typed arrays for numeric data. A <code>Float32Array</code> of 60,000 floats is 240KB. A plain JavaScript array of the same size is around 1.4MB and creates GC pressure on every push.</p>
<p>Second, bound everything. Use ring buffers, not growing arrays. Put caps on history, queues, and pending work. Anything that <em>can</em> grow unbounded eventually will.</p>
<p>Third, reuse buffers. Allocate once in setup, and reuse across frames. Allocating a fresh array per frame at 60fps is 60 allocations per second per plot. Multiply by plot count.</p>
<pre><code class="language-ts">// Bad: fresh array every frame.
function drawFrame() {
  const snapshot = store.read(); // returns a new Float32Array
  drawDecimated(ctx, snapshot, width);
}

// Better: reuse a draw buffer.
const drawBuf = new Float32Array(WINDOW_SIZE);

function drawFrame() {
  store.readInto(drawBuf); // writes into the existing buffer
  drawDecimated(ctx, drawBuf, width);
}
</code></pre>
<p>These rules aren't React-specific. They're the rules any real-time system follows. The reason they need stating is that React-shaped thinking ("derive a new array each render") is the opposite of what real-time wants.</p>
<h2 id="heading-step-9-scheduling-strategies">Step 9: Scheduling Strategies</h2>
<p>A worker pool with no scheduling is just a queue. Sometimes you want priority: a user-driven action should jump ahead of a background sweep.</p>
<h3 id="heading-priority-queues">Priority Queues</h3>
<p>A small priority scheduler:</p>
<pre><code class="language-ts">type Priority = "high" | "normal" | "low";

class PriorityPool {
  private queues: Record&lt;Priority, Job[]&gt; = { high: [], normal: [], low: [] };
  private idle: Worker[];

  enqueue(job: Job, priority: Priority = "normal") {
    if (this.idle.length &gt; 0) {
      const worker = this.idle.pop()!;
      this.dispatch(worker, job);
    } else {
      this.queues[priority].push(job);
    }
  }

  private next(): Job | null {
    for (const p of ["high", "normal", "low"] as const) {
      const j = this.queues[p].shift();
      if (j) return j;
    }
    return null;
  }

  private dispatch(worker: Worker, job: Job) {
    worker.postMessage(job.payload, job.transferables);
    worker.onmessage = (event) =&gt; {
      job.resolve(event.data);
      const next = this.next();
      if (next) this.dispatch(worker, next);
      else this.idle.push(worker);
    };
  }
}
</code></pre>
<p>Three buckets are usually enough: high (user-initiated), normal (steady-state work), low (background sweeps, prefetch, telemetry flushing).</p>
<h3 id="heading-chunkable-work">Chunkable Work</h3>
<p>A job that takes 2 seconds blocks a worker for 2 seconds. If you want to keep workers responsive to higher-priority jobs, the job has to be chunkable.</p>
<pre><code class="language-ts">// In the worker:
self.onmessage = async (event) =&gt; {
  const { id, kind, payload } = event.data;
  if (kind === "decode_large") {
    const total = payload.byteLength;
    for (let i = 0; i &lt; total; i += CHUNK) {
      const chunk = decodeChunk(payload, i, Math.min(i + CHUNK, total));
      self.postMessage({ id, kind: "progress", chunk, offset: i });
      // Yield so the worker can check its message queue.
      await new Promise((r) =&gt; setTimeout(r, 0));
    }
    self.postMessage({ id, kind: "done" });
  }
  if (kind === "cancel") {
    // ... abort the current job
  }
};
</code></pre>
<p>Yielding inside a worker isn't free, but it lets the worker process cancellation messages or higher-priority jobs interleaved with the big task. For long jobs (like a firmware flash, large file decode, or big render), this is essential.</p>
<h3 id="heading-fan-out-and-fan-in">Fan-out and Fan-in</h3>
<p>Split a big job into N pieces, dispatch each to a different worker, and gather the results.</p>
<pre><code class="language-ts">async function decodeFile(buffer: ArrayBuffer): Promise&lt;DecodedFrame[]&gt; {
  const chunks = splitBuffer(buffer, 8);
  const results = await Promise.all(
    chunks.map((chunk) =&gt; pool.run&lt;DecodedFrame[]&gt;("decode", chunk, [chunk])),
  );
  return results.flat();
}
</code></pre>
<p>This gives you linear speedup on parallelisable workloads, up to the worker count. It's critical for batch operations like decoding a recording, summarising a long document, or computing embeddings for a folder.</p>
<h2 id="heading-step-10-measure-sustained-performance">Step 10: Measure Sustained Performance</h2>
<p>A spike on the Profiler is one thing. A slow heap creep over an hour is another. For real-time apps, you measure two things.</p>
<p>First, frame consistency. Are you holding 60fps consistently, or dropping occasional frames? A simple FPS meter:</p>
<pre><code class="language-ts">let lastTime = performance.now();
let frames = 0;

function rafLoop(now: number) {
  frames++;
  if (now - lastTime &gt;= 1000) {
    const fps = (frames * 1000) / (now - lastTime);
    frames = 0;
    lastTime = now;
    console.log(`fps: ${fps.toFixed(1)}`);
  }
  requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
</code></pre>
<p>A more honest measure is the worst frame in a window. Average frame time hides the jank.</p>
<p>Second, heap stability. Open DevTools Memory tab, take a heap snapshot, run the app for ten minutes, take another snapshot, and then compare. The diff should be flat. If it's growing, you have a leak: retained listeners, growing arrays, or closures holding onto large objects.</p>
<p>For real apps, wire heap usage and FPS into your analytics so you spot regressions before users complain.</p>
<h2 id="heading-case-study-19-eeg-channels">Case Study: 19 EEG Channels</h2>
<p>Back to the project that started this article: we have nineteen channels, each at 1kHz, all rendered simultaneously, and all needing to stay smooth across multi-hour recording sessions.</p>
<p>The first build used <strong>LightningChart</strong>. It's powerful, capable, and beautiful. But it's also heavy. Memory usage climbed noticeably, the chart's own internals were doing a lot of work for our use case (which was 19 simple line plots, not full multi-axis financial charts), and the licensing was a friction point.</p>
<p>We switched to <strong>uPlot</strong>. It's tiny, fast, and written specifically for time-series. Memory usage dropped, render time per frame went from "occasionally over budget" to "always under," and my machine stopped sounding like it was about to take off. The chart library change alone bought us most of the headroom we needed.</p>
<p>The architecture around the chart did the rest. The pipeline runs on four threads:</p>
<ul>
<li><p><strong>One ingest worker</strong> reads from the device SDK over IPC (Electron main process to renderer).</p>
</li>
<li><p><strong>One</strong> <code>SharedArrayBuffer</code> holds the rolling window for all 19 channels.</p>
</li>
<li><p><strong>One render worker</strong> reads the SAB and draws to an <code>OffscreenCanvas</code>.</p>
</li>
<li><p><strong>Main thread</strong> renders chrome: channel labels, controls, and the FPS meter.</p>
</li>
</ul>
<p>The shared buffer layout is one big <code>Float32Array</code> indexed as <code>[channel * samplesPerChannel + sampleIndex]</code>. The ingest worker writes new samples and advances per-channel head pointers (stored in an <code>Int32Array</code> slice of the SAB). The render worker reads the latest window each frame.</p>
<pre><code class="language-ts">const CHANNELS = 19;
const WINDOW_SAMPLES = 60_000;

const sab = new SharedArrayBuffer(CHANNELS * WINDOW_SAMPLES * 4 + CHANNELS * 4);
const heads = new Int32Array(sab, 0, CHANNELS);
const samples = new Float32Array(sab, CHANNELS * 4, CHANNELS * WINDOW_SAMPLES);

function writeSample(channel: number, value: number) {
  const head = Atomics.load(heads, channel);
  samples[channel * WINDOW_SAMPLES + head] = value;
  Atomics.store(heads, channel, (head + 1) % WINDOW_SAMPLES);
}
</code></pre>
<p>The renderer reads the channel buffers, decimates per pixel column, and draws:</p>
<pre><code class="language-ts">// render.worker.ts
function drawFrame() {
  const ctx = offscreenCtx;
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (let ch = 0; ch &lt; CHANNELS; ch++) {
    const start = ch * WINDOW_SAMPLES;
    drawDecimatedRow(ctx, samples.subarray(start, start + WINDOW_SAMPLES), ch);
  }
  requestAnimationFrame(drawFrame);
}
</code></pre>
<p>On a 2024 M3 MacBook Pro, this holds 60fps with 19 channels at 1kHz, and 144fps if the display supports it. The main thread stays under 1% utilisation. Workers consume the spare cores. The user feels something that used to require a native app.</p>
<p>The lesson, in short: most of the work is choosing the right tool for the inner loop, and getting out of its way.</p>
<h2 id="heading-benchmarks-single-thread-vs-multi-thread">Benchmarks: Single Thread vs Multi-Thread</h2>
<p>Here are numbers from running comparable workloads on a 2024 M3 MacBook Pro. They're indicative, not promissory.</p>
<table>
<thead>
<tr>
<th>Workload</th>
<th>Main thread only</th>
<th>Worker pool</th>
<th>Workers + SAB</th>
<th>Workers + SAB + OffscreenCanvas</th>
</tr>
</thead>
<tbody><tr>
<td>Parse 100MB binary file</td>
<td>4.2s (UI frozen)</td>
<td>1.1s</td>
<td>1.0s</td>
<td>1.0s</td>
</tr>
<tr>
<td>Decode 1,000 frames</td>
<td>920ms</td>
<td>280ms</td>
<td>240ms</td>
<td>240ms</td>
</tr>
<tr>
<td>Render 1M-point chart</td>
<td>24fps</td>
<td>24fps</td>
<td>28fps</td>
<td>60fps</td>
</tr>
<tr>
<td>Telemetry: 4 streams x 1000Hz</td>
<td>22fps</td>
<td>38fps</td>
<td>55fps</td>
<td>60fps</td>
</tr>
<tr>
<td>EEG: 19 channels x 1kHz</td>
<td>12fps</td>
<td>25fps</td>
<td>48fps</td>
<td>60fps (144fps possible)</td>
</tr>
<tr>
<td>Main-thread JS time per frame</td>
<td>22ms</td>
<td>8ms</td>
<td>4ms</td>
<td>&lt; 1ms</td>
</tr>
<tr>
<td>Memory overhead</td>
<td>baseline</td>
<td>+50MB</td>
<td>+20MB</td>
<td>+20MB</td>
</tr>
<tr>
<td>Worker spin-up latency (first call)</td>
<td>0</td>
<td>2-5ms</td>
<td>2-5ms</td>
<td>5-10ms</td>
</tr>
</tbody></table>
<p>The pattern: workers alone help, and workers plus shared memory help more. Workers plus shared memory plus <code>OffscreenCanvas</code> is what gets you to "the main thread is doing nothing and the chart is still smooth."</p>
<p>For chart-heavy apps, the leap from "workers" to "workers plus <code>OffscreenCanvas</code>" is the biggest single architectural improvement available without leaving the browser.</p>
<h2 id="heading-the-coopcoep-catch">The COOP/COEP Catch</h2>
<p><code>SharedArrayBuffer</code> and high-resolution timers were tightened in 2020 after Spectre/Meltdown. To use them, your page must be served with two HTTP headers:</p>
<pre><code class="language-plaintext">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>This opts your page into "cross-origin isolation." Within an isolated context, <code>SharedArrayBuffer</code> exists, <code>performance.now()</code> is high-resolution, and various other restricted APIs work.</p>
<p>Outside an isolated context, <code>SharedArrayBuffer</code> is undefined, <code>performance.now()</code> is throttled to about 1ms precision, and Atomics throw.</p>
<p>The cost is significant. <code>require-corp</code> means every cross-origin resource (images from a CDN, embedded YouTube videos, third-party fonts, analytics scripts) must explicitly opt in by setting <code>Cross-Origin-Resource-Policy: cross-origin</code> or <code>Cross-Origin-Embedder-Policy: credentialless</code>. Many third-party services don't, which breaks their embedding.</p>
<p>There are two practical options:</p>
<ul>
<li><p><strong>For an Electron app:</strong> the renderer process can be configured to use these headers easily. Most production Electron apps that want real-time visualisation enable them by default.</p>
</li>
<li><p><strong>For a browser app:</strong> weigh the embeds you'd lose against the performance you'd gain. If your app is the main attraction (Figma, Google Docs), opt in. If you depend on third-party widgets, the cost is real.</p>
</li>
</ul>
<p>For chart-heavy or signal-heavy apps that need SAB, the Electron path is usually cleaner.</p>
<h2 id="heading-production-tradeoffs">Production Tradeoffs</h2>
<p>Here are the five real costs of everything above.</p>
<ul>
<li><p><strong>Code complexity:</strong> A worker-driven app has 2 to 4 times the source files of a single-threaded one (main + workers + shared types). It's worth it for the right scale, but painful for a trivial app.</p>
</li>
<li><p><strong>Debugging:</strong> Stack traces split across threads. Chrome DevTools handles this well in 2026 (each worker has its own debugger panel), but it's still more work than a single-thread bug.</p>
</li>
<li><p><strong>Bundle size:</strong> Each worker is a separate chunk. Tree-shaking inside workers is sometimes worse than in main (less mature). Audit worker bundles separately.</p>
</li>
<li><p><strong>Startup latency:</strong> Spinning up workers at app start adds 50 to 200ms. Pre-warm them during the splash screen, or accept the first-frame delay.</p>
</li>
<li><p><strong>Browser API gaps:</strong> <code>localStorage</code>, <code>document</code>, and most DOM APIs aren't available in workers. Some libraries silently rely on them and break. Test in a worker context before bundling a library you haven't tried there.</p>
</li>
</ul>
<p>There are three trade-offs specific to the imperative rendering pattern:</p>
<ul>
<li><p><strong>Declarative animation of the data:</strong> The chart frame, labels, and controls all stay declarative. The data inside the chart becomes imperative.</p>
</li>
<li><p><strong>Easy snapshot testing of the rendered output:</strong> A canvas has no DOM you can query. Test the data path separately from the draw path. Snapshot the store output, not the pixels.</p>
</li>
<li><p><strong>React's component story for the inner loop:</strong> The draw loop is a closure. Composing draw loops is harder than composing components. Pick your component boundary carefully so each canvas does one thing.</p>
</li>
</ul>
<p>For most apps these costs aren't worth paying. For an app that has to render 1kHz data smoothly, they're the price of admission.</p>
<h2 id="heading-should-you-build-like-this">Should You Build Like This?</h2>
<p>If your data rate is below 30 updates per second per stream, none of this is needed. Naïve <code>setState</code> per batch will work. Profile first, optimise second.</p>
<p>This architecture earns its keep when:</p>
<ul>
<li><p>You have many streams or sensors</p>
</li>
<li><p>Ingestion is sustained, not bursty</p>
</li>
<li><p>The UX promise is smooth motion for hours, not seconds</p>
</li>
<li><p>You'd rather not rewrite the UI in a native language to get there</p>
</li>
</ul>
<p>The boring rule: start with <code>requestAnimationFrame</code> coalescing and external stores. Promote to workers when those aren't enough. Promote to shared memory when worker <code>postMessage</code> is the bottleneck. Promote to <code>OffscreenCanvas</code> when the render loop itself becomes the bottleneck. Each step is a real architectural investment. Take them in order.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>React can handle real-time visualisation if you use it the right way. Instead of pushing React to do everything, use it as the conductor. Let specialised libraries and workers handle the heavy lifting.</p>
<p>These are the three rules that hold up across every high-frequency React app I've built:</p>
<ol>
<li><p><strong>Workers do the work, while main does the UI.</strong> If main is doing math, you've put the math in the wrong place.</p>
</li>
<li><p><strong>Transfer if you can, share if you must.</strong> Both beat cloning. Sharing is more complex than transferring.</p>
</li>
<li><p><strong>Let React orchestrate. Let specialised tools render.</strong> The store owns the data, the draw loop owns the values, and React owns the shape.</p>
</li>
</ol>
<p>Get those right and your React app stops being a one-core system that flinches at high-frequency data. It becomes a real multi-core system that scales with the hardware, ingests without dropping, and renders without stuttering.</p>
<p>The cores are right there. Use them.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://github.com/leeoniya/uPlot">uPlot</a>: tiny, fast, focused on time-series.</p>
</li>
<li><p><a href="https://github.com/huww98/TimeChart">TimeChart</a>: high-performance real-time chart on WebGL.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">Using Web Workers in React</a>: MDN reference.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">SharedArrayBuffer on MDN</a>: the shared-memory primitive.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas">OffscreenCanvas on MDN</a>: rendering off the main thread.</p>
</li>
<li><p><a href="https://web.dev/articles/coop-coep">COOP and COEP explainer</a>: what cross-origin isolation buys you.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Scalable Design System in a Monorepo ]]>
                </title>
                <description>
                    <![CDATA[ When you hear "Scalable Design System with a Monorepo Ecosystem" it might sound like a bunch of jargon glued together. Let's simplify: Design system: the building blocks of your product (buttons, inp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-scalable-design-system-in-a-monorepo/</link>
                <guid isPermaLink="false">6a397b0b12901591d0138d81</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Design Systems ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monorepo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Mon, 22 Jun 2026 18:12:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e1f42e08-4158-4ecb-8d71-5371cfe86707.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you hear "Scalable Design System with a Monorepo Ecosystem" it might sound like a bunch of jargon glued together. Let's simplify:</p>
<ul>
<li><p><strong>Design system</strong>: the building blocks of your product (buttons, inputs, styles, tokens, patterns).</p>
</li>
<li><p><strong>Monorepo</strong>: one big repo with multiple packages living together, sharing tooling and workflows.</p>
</li>
</ul>
<p>Now here's the magic: when you combine them, you get modularity, consistency, and a faster development cycle. Basically the dream setup for teams working across web, mobile, and beyond.</p>
<p>In this article, you'll learn how to build a modular, scalable design system using React and Turborepo – the same approach used by Microsoft, IBM, and Shopify.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-whos-already-doing-this">Who's Already Doing This?</a></p>
</li>
<li><p><a href="#heading-why-it-works">Why it Works</a></p>
</li>
<li><p><a href="#heading-think-of-it-like-a-ladder">Think of it Like a Ladder</a></p>
</li>
<li><p><a href="#heading-the-same-design-system-everywhere">The Same Design System, Everywhere</a></p>
</li>
<li><p><a href="#heading-should-you-go-monorepo">Should You Go Monorepo?</a></p>
</li>
<li><p><a href="#heading-when-a-monorepo-is-not-the-right-fit">When a Monorepo Is Not the Right Fit</a></p>
</li>
<li><p><a href="#heading-lets-build-our-design-system">Let's Build Our Design System</a></p>
<ul>
<li><p><a href="#heading-create-your-turborepo-project">Create Your Turborepo Project</a></p>
</li>
<li><p><a href="#heading-design-your-package-structure">Design Your Package Structure</a></p>
</li>
<li><p><a href="#heading-build-your-design-tokens-package">Build Your Design Tokens Package</a></p>
</li>
<li><p><a href="#heading-create-primitive-components">Create Primitive Components</a></p>
</li>
<li><p><a href="#heading-configure-the-turborepo-pipeline">Configure the Turborepo Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-yourds-packages">Build the @yourds Packages</a></p>
</li>
<li><p><a href="#heading-use-your-design-system-in-an-app">Use Your Design System in an App</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you follow along, you'll want to have a few things in place:</p>
<ul>
<li><p><strong>Working knowledge of React and TypeScript:</strong> You should be comfortable creating components and reading basic type annotations.</p>
</li>
<li><p><strong>Familiarity with the command line:</strong> You'll run <code>npx</code>, <code>npm</code>, and similar commands throughout.</p>
</li>
<li><p><strong>Node.js installed (v18 or later)</strong>: Verify with <code>node -v</code>. If you don't have it, install it from <a href="https://nodejs.org">nodejs.org</a>.</p>
</li>
<li><p><strong>A package manager:</strong> This guide uses <code>npm</code>, but <code>pnpm</code> or <code>yarn</code> will work with minor command tweaks.</p>
</li>
<li><p><strong>A code editor</strong> of your choice (VS Code is a popular fit for TypeScript work).</p>
</li>
</ul>
<p>You don't need any prior experience with monorepos or Turborepo. We'll set everything up from scratch.</p>
<h2 id="heading-whos-already-doing-this">Who's Already Doing This?</h2>
<p>Turns out, some of the biggest design systems you've heard of run inside monorepos:</p>
<ol>
<li><p><a href="https://github.com/microsoft/fluentui/wiki/Fluent-UI-React-Repo-Structure/d7060a0782b639b657cf7a9c0826bff757ad78b5">Microsoft Fluent UI</a>: lives in a multi-package monorepo that ships React components, Web Components, and even design tokens.</p>
</li>
<li><p><a href="https://github.com/carbon-design-system/ibm-products">IBM Carbon</a>: multiple packages like <code>@carbon/ibm-products</code> come straight out of their Carbon monorepo.</p>
</li>
<li><p><a href="https://github.com/Shopify/polaris-react">Shopify Polaris</a>: openly describes itself as a monorepo, packaging React components, docs, and even a VS Code extension.</p>
</li>
<li><p><a href="https://github.com/atlassian/pragmatic-drag-and-drop">Atlassian Atlaskit</a>: their public <code>@atlaskit/*</code> packages are published from a large internal monorepo.</p>
</li>
<li><p><a href="https://github.com/mui/mui-public/tree/master">MUI</a> (Material UI): maintained as a mono-repository to coordinate React components, tooling, and docs.</p>
</li>
<li><p><a href="https://github.com/elastic/eui">Elastic EUI</a>: developed and released from a single repo, with discussions about monorepo publishing flows.</p>
</li>
</ol>
<h2 id="heading-why-it-works">Why it Works</h2>
<p>When you put all the pieces of your design system in one repository, you get a few specific advantages that are hard to replicate in a split-repo setup. Each of these reinforces the others, which is why teams that adopt this pattern rarely go back.</p>
<p>Here's what makes it work:</p>
<ul>
<li><p><strong>Consistency</strong>: tokens, styles, and primitives are defined once and flow everywhere.</p>
</li>
<li><p><strong>Faster iteration</strong>: fix a bug in Button and the updates cascade to mobile, desktop, and docs instantly.</p>
</li>
<li><p><strong>Shared tooling</strong>: linting, tests, CI pipelines, and release workflows are configured once, and then applied to all packages.</p>
</li>
<li><p><strong>Versioning control</strong>: with tools like Changesets or Lerna, you can release packages independently but keep them aligned.</p>
</li>
<li><p><strong>Cross-platform flexibility</strong>: the same building blocks can power React web apps, React Native, Electron apps, SDKs, and documentation sites.</p>
</li>
</ul>
<h2 id="heading-think-of-it-like-a-ladder">Think of it Like a Ladder 🪜</h2>
<p>The cleanest way to picture a monorepo design system is as a series of stacked layers. Each layer builds on the one beneath it, and each layer has a clear job.</p>
<p>New contributors find their way around faster because the relationships between packages are predictable: tokens flow up into primitives, primitives compose into layouts, and layouts assemble into screens.</p>
<p>The diagram below shows this stack visually:</p>
<img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhhcenvi46zcjfwrl1odj.png" alt="Layered architecture of a monorepo design system: design tokens at the base, then plugins (utility helpers), then layouts, then screens, then navigators at the top, with the app shell consuming a single package that pulls all layers together" style="display: block;" width="800" height="500" loading="lazy">

<p>At the base, you've got <code>primitives</code> (tokens, styles).</p>
<p>Above that: <code>plugins</code> (utility helpers).</p>
<p>Then come <code>layouts</code>, built from plugins + primitives.</p>
<p>Then <code>screens</code>, built from layouts.</p>
<p>Finally, <code>navigators</code> tie screens together.</p>
<p>At the very top: your app imports just one package, and boom! The UI is environment-agnostic.</p>
<h2 id="heading-the-same-design-system-everywhere">The Same Design System, Everywhere</h2>
<p>The real payoff of this ladder is that you climb it once, then reuse the whole thing across every platform you ship to.</p>
<p>A button defined in your <code>primitives</code> package can render in a web app, a React Native mobile app, an Electron desktop app, or a documentation site without you rewriting it for each environment.</p>
<p>The diagram below shows the same design system flowing into three different app types, with each environment importing the same package and getting consistent styling, behaviour, and accessibility out of the box:</p>
<img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqsa4y8m103unz7hefr3u.png" alt="The same design system feeding three different apps from a single import: a web application on a browser, a desktop application in an Electron-style window, and a mobile application on a phone screen. Each app pulls from the shared primitives and tokens packages, ensuring buttons, typography, and spacing look and behave the same everywhere" style="display: block;" width="800" height="500" loading="lazy">

<p>Whether it's web, desktop, or mobile, the design system climbs that same ladder.</p>
<h2 id="heading-should-you-go-monorepo">Should You Go Monorepo?</h2>
<p>Not every team needs one. But if you're building a design system that's meant to serve multiple apps, stay consistent across platforms, and support lots of contributors, then a monorepo becomes less of a buzzword and more of a sanity-saver.</p>
<h2 id="heading-when-a-monorepo-is-not-the-right-fit">When a Monorepo Is Not the Right Fit</h2>
<p>A quick clarification first, because monorepos sometimes get tangled up with another debate. The "monorepo vs polyrepo" question is <strong>not</strong> the same as the "monolith vs microservices" question. You can absolutely run microservices out of a monorepo (Google and Facebook do this at massive scale).</p>
<p>The two choices live on different axes: monorepo vs polyrepo is about <em>where the code lives</em>, while monolith vs microservices is about <em>how the runtime is shaped</em>.</p>
<p>With that out of the way, here are a few signs a monorepo may not be the best fit for your situation:</p>
<ul>
<li><p><strong>You're a small team shipping a single product.</strong> The tooling overhead of a monorepo (workspace config, build pipelines, package boundaries) may slow you down more than it helps. A single React app with no shared libraries probably doesn't need this layer.</p>
</li>
<li><p><strong>Your packages have wildly different release cadences and stakeholders.</strong> If two parts of your codebase are owned by teams that need very different deploy pipelines, governance, or security postures, separate repos can reduce friction.</p>
</li>
<li><p><strong>You can't invest in monorepo tooling.</strong> Tools like Turborepo, Nx, and Changesets do a lot of heavy lifting, but they have a learning curve. If your team can't dedicate time to set them up and maintain them, you may struggle.</p>
</li>
<li><p><strong>You're using languages or runtimes that don't share well.</strong> Monorepos shine when most packages live in the same toolchain. Mixing Node, Go, Rust, and Python in one repo is possible, but the build-tool story gets harder.</p>
</li>
</ul>
<p>For most teams building a serious design system, none of these are dealbreakers. But it's worth checking your situation before committing.</p>
<h2 id="heading-lets-build-our-design-system">Let's Build Our Design System</h2>
<h3 id="heading-create-your-turborepo-project">Create Your Turborepo Project</h3>
<p>Start by creating a new Turborepo project. This gives you the perfect foundation for a scalable monorepo.</p>
<pre><code class="language-plaintext"># Create a new Turborepo project
npx create-turbo@latest my-design-system

# Navigate to the project
cd my-design-system

# Install dependencies
npm install
</code></pre>
<p>Turborepo creates a workspace with <code>apps/</code> and <code>packages/</code> folders, shared tooling configuration, and optimized build pipelines.</p>
<h3 id="heading-design-your-package-structure">Design Your Package Structure</h3>
<p>Next, create a logical hierarchy for your design system packages. Think of it like a ladder, as I mentioned above: each level builds on the one below.</p>
<pre><code class="language-plaintext">my-design-system/
├── packages/
│   ├── tokens/          # Design tokens (colors, spacing, typography)
│   ├── primitives/      # Base components (Button, Input, Card)
│   ├── layouts/         # Layout components (Grid, Stack, Container)
├── apps/
│   ├── web/            # Example web app
│   └── docs/           # Documentation site
└── turbo.json          # Turborepo configuration
</code></pre>
<h4 id="heading-detailed-file-structure">Detailed file structure</h4>
<pre><code class="language-plaintext">my-design-system/
├── packages/
│   ├── tokens/
│   │   ├── src/
│   │   │   ├── colors.ts
│   │   │   ├── spacing.ts
│   │   │   ├── typography.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── primitives/
│   │   ├── src/
│   │   │   ├── Button/
│   │   │   │   └── Button.tsx
│   │   │   ├── Input/
│   │   │   │   └── Input.tsx
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── layouts/
│   │   ├── src/
│   │   │   ├── Grid/
│   │   │   ├── Stack/
│   │   │   └── index.ts
│   │   └── package.json
├── apps/
│   ├── web/
│   │   ├── src/
│   │   │   ├── App.tsx
│   │   │   └── main.tsx
│   │   ├── index.html
│   │   └── package.json
│   └── docs/
│       ├── src/
│       └── package.json
├── turbo.json
├── package.json
└── README.md
</code></pre>
<h3 id="heading-build-your-design-tokens-package">Build Your Design Tokens Package</h3>
<p>Start with the foundation: <strong>design tokens</strong>. Tokens are the smallest, most reusable units of a design system: a color value, a spacing step, a font size, a border radius. Instead of hard-coding <code>padding: 16px</code> or <code>color: #3b82f6</code> everywhere, you reference a token like <code>spacing.md</code> or <code>colors.primary[500]</code>.</p>
<p>The benefits are huge:</p>
<ul>
<li><p><strong>One place to change a value:</strong> update a token once and every component that uses it updates automatically.</p>
</li>
<li><p><strong>Theming becomes trivial:</strong> want a dark mode? Just swap which tokens resolve to which values.</p>
</li>
<li><p><strong>Cross-platform consistency:</strong> the same token names work in web CSS, native styles, even Figma.</p>
</li>
</ul>
<p>Tokens are the DNA of your design system. Let's build them.</p>
<pre><code class="language-plaintext"># Create the tokens package
mkdir -p packages/tokens/src
cd packages/tokens
</code></pre>
<p>Update these in your <code>packages/tokens/package.json</code>. This file declares the package name, version, build scripts, and dev dependencies needed to compile the token source files into a publishable package:</p>
<pre><code class="language-json">{
  "name": "@yourds/tokens",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}
</code></pre>
<p>Update these in your <code>packages/tokens/src/colors.ts</code>. This file defines the <strong>color tokens</strong>: a named palette of color values organised by intent (primary, gray) and shade (50 is lightest, 900 is darkest). Components reference these by name rather than hardcoding hex codes:</p>
<pre><code class="language-javascript">export const colors = {
  primary: {
    50: '#f0f9ff',
    100: '#e0f2fe',
    500: '#3b82f6',
    600: '#2563eb',
    900: '#1e3a8a'
  },
  gray: {
    50: '#f9fafb',
    100: '#f3f4f6',
    500: '#6b7280',
    900: '#111827'
  }
} as const;
</code></pre>
<p>Update these in your <code>packages/tokens/src/spacing.ts</code>. This file defines the <strong>spacing scale</strong>: a set of standard size steps that components use for padding, margin, and gap values. Using a fixed scale (xs, sm, md, lg, and so on) keeps spacing consistent across the UI:</p>
<pre><code class="language-typescript">export const spacing = {
  xs: '0.25rem',    // 4px
  sm: '0.5rem',     // 8px
  md: '1rem',       // 16px
  lg: '1.5rem',     // 24px
  xl: '2rem',       // 32px
  '2xl': '3rem'     // 48px
} as const;
</code></pre>
<p>Update these in your <code>packages/tokens/src/typography.ts</code>. This file defines the <strong>typography tokens</strong>: font sizes and font weights that components use for text. Like spacing, these are named steps rather than arbitrary pixel values:</p>
<pre><code class="language-typescript">export const typography = {
  fontSizes: {
    xs: '0.75rem',
    sm: '0.875rem',
    base: '1rem',
    lg: '1.125rem',
    xl: '1.25rem',
    '2xl': '1.5rem'
  },
  fontWeights: {
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700
  }
} as const;
</code></pre>
<p>Update these in your <code>packages/tokens/src/index.ts</code>. This file is the <strong>public entry point</strong> of the package: it re-exports everything from the three token files so consumers can do <code>import { colors, spacing, typography } from "@yourds/tokens"</code> in a single line:</p>
<pre><code class="language-typescript">export * from './colors';
export * from './spacing';
export * from './typography';
</code></pre>
<h3 id="heading-create-primitive-components">Create Primitive Components</h3>
<p>Build your base components that consume the design tokens:</p>
<pre><code class="language-plaintext"># Create the primitives package
mkdir -p packages/primitives/src
cd packages/primitives

# Install dependencies
npm install react react-dom
</code></pre>
<p>Update these in your <code>packages/primitives/package.json</code>:</p>
<pre><code class="language-json">{
  "name": "@yourds/primitives",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts --external react",
    "dev": "tsup src/index.ts --format cjs,esm --dts --external react --watch"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.0",
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}
</code></pre>
<p>Update these in your <code>packages/primitives/src/Button/Button.tsx</code>:</p>
<pre><code class="language-typescript">import React from 'react';
import { colors, spacing } from '@yourds/tokens';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'outline';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () =&gt; void;
  disabled?: boolean;
}

export const Button: React.FC&lt;ButtonProps&gt; = ({
  variant = 'primary',
  size = 'md',
  children,
  disabled = false,
  ...props
}) =&gt; {
  const baseStyles = {
    border: 'none',
    borderRadius: '0.5rem',
    cursor: disabled ? 'not-allowed' : 'pointer',
    fontWeight: 500,
    transition: 'all 0.2s ease',
    opacity: disabled ? 0.6 : 1
  };

  const variants = {
    primary: {
      backgroundColor: colors.primary[500],
      color: 'white',
      ':hover': { backgroundColor: colors.primary[600] }
    },
    secondary: {
      backgroundColor: colors.gray[100],
      color: colors.gray[900],
      ':hover': { backgroundColor: colors.gray[200] }
    },
    outline: {
      backgroundColor: 'transparent',
      color: colors.primary[500],
      border: `1px solid ${colors.primary[500]}`,
      ':hover': { backgroundColor: colors.primary[50] }
    }
  };

  const sizes = {
    sm: { padding: `\({spacing.xs} \){spacing.sm}`, fontSize: '0.875rem' },
    md: { padding: `\({spacing.sm} \){spacing.md}`, fontSize: '1rem' },
    lg: { padding: `\({spacing.md} \){spacing.lg}`, fontSize: '1.125rem' }
  };

  const buttonStyle = {
    ...baseStyles,
    ...variants[variant],
    ...sizes[size]
  };

  return (
    &lt;button style={buttonStyle} disabled={disabled} {...props}&gt;
      {children}
    &lt;/button&gt;
  );
};
</code></pre>
<p>Update these in your <code>packages/primitives/src/index.ts</code>:</p>
<pre><code class="language-typescript">export { Button } from './Button/Button';
export type { ButtonProps } from './Button/Button';
</code></pre>
<h3 id="heading-configure-the-turborepo-pipeline">Configure the Turborepo Pipeline</h3>
<p>Now, set up the build pipeline in <code>turbo.json</code> to ensure packages build in the correct order.</p>
<pre><code class="language-json">{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}
</code></pre>
<h3 id="heading-build-the-yourds-packages">Build the @yourds Packages</h3>
<p>With the tokens and primitives packages defined, the next step is to compile them so they can be consumed by your apps.</p>
<p>Running <code>npm install</code> at the root resolves all workspace dependencies, including the internal links between <code>@yourds/tokens</code> and <code>@yourds/primitives</code>. Then <code>npm run build</code> walks through every package and runs each one's <code>build</code> script, which Turborepo orders correctly so <code>tokens</code> compiles before <code>primitives</code> (since primitives depend on tokens). The final <code>npm install</code> step then registers the built packages so your <code>apps/web</code> app can import them by name:</p>
<pre><code class="language-plaintext"># Go to the root of the monorepo
npm install

# Compile every package in the right order
npm run build

# Register the built packages for the apps to use
npm install @yourds/tokens @yourds/primitives
</code></pre>
<p>If everything ran successfully, you should see a <code>dist/</code> folder inside both <code>packages/tokens</code> and <code>packages/primitives</code>, containing compiled JavaScript and TypeScript declaration files.</p>
<h3 id="heading-use-your-design-system-in-an-app">Use Your Design System in an App</h3>
<p>Now you can consume your design system in any React application.</p>
<p>The example below replaces the default content in your <code>apps/web/src/App.tsx</code> file with a small home page that demonstrates two things at once: importing primitives (the <code>Button</code> component) from <code>@yourds/primitives</code>, and importing tokens (<code>colors</code>, <code>spacing</code>) directly from <code>@yourds/tokens</code> to style standard HTML elements like the wrapper <code>&lt;div&gt;</code> and the <code>&lt;h1&gt;</code>.</p>
<p>The result is a fully working page that uses your design system end-to-end, with zero hardcoded colors or spacing values:</p>
<pre><code class="language-typescript">import { Button } from "@yourds/primitives";
import { colors, spacing } from "@yourds/tokens";

export default function Home() {
  return (
    &lt;div style={{ padding: spacing.lg }}&gt;
      &lt;h1 style={{ color: colors.primary[500] }}&gt;My App with Design System&lt;/h1&gt;
      &lt;Button variant="primary" size="lg"&gt;
        Get Started
      &lt;/Button&gt;
      &lt;Button variant="outline" size="md"&gt;
        Learn More
      &lt;/Button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Once you save the file, run the app in development mode:</p>
<pre><code class="language-plaintext">npx turbo dev --filter=web
</code></pre>
<p>You should see your home page render with the <code>primary[500]</code> blue heading, padded by <code>spacing.lg</code>, and two buttons styled by your shared design system. Any change you make to a token (say, swapping the primary color) will flow into this page automatically the next time you rebuild.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>A monorepo won't magically make your design system perfect. But it does give you:</p>
<ul>
<li><p>A shared space where everything connects</p>
</li>
<li><p>The agility to publish parts independently</p>
</li>
<li><p>The clarity to scale design across teams and platforms</p>
</li>
</ul>
<p>No wonder the biggest design systems in the world are already doing it.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
