Jake Goldsborough

Rewriting discourse-comments in TypeScript: Dropping WASM for a 97% Smaller Bundle

Feb 27, 2026

4 min read

Last month I wrote about building an embedded comment widget for Discourse using Rust compiled to WebAssembly. It worked. Users could drop a single script tag on their page and get comments from a Discourse topic.

But the bundle was 742 KB. Most of that was WASM.

I rewrote the API client in TypeScript and the bundle dropped to 18.5 KB. Same functionality, same API surface, no WASM runtime.

Why Rewrite?

The original stack had three layers: a Rust HTTP client compiled to WASM, a TypeScript web component, and an esbuild plugin to inline the WASM binary as base64. That last part was the real cost. The WASM binary was ~546 KB, and base64 encoding inflates that by 33%.

The Rust client existed because I wanted to push Rust into the browser. It was a fun experiment. But the API client itself was straightforward HTTP: build a URL, set some headers, parse JSON. There's nothing in that workflow that benefits from Rust's type system or performance characteristics. The browser's native fetch does the same thing.

So I asked the obvious question: what if the API client was just TypeScript?

The Rewrite

The new client is discourse-api-ts. Zero runtime dependencies. It uses fetch for HTTP, which every modern browser and Node.js already provides.

The Rust client had 18 methods across topics, posts, categories, chat, notifications, and likes. The TypeScript port has the same 18 methods with identical signatures. The only difference is BigInt parameters became regular number (JavaScript's Number handles Discourse IDs fine).

Here's what the client looks like:

// Anonymous (read-only)
const client = new DiscourseClient("https://forum.example.com");

// Authenticated
const client = DiscourseClient.withUserApiKey(
  "https://forum.example.com",
  storedApiKey
);

const topic = await client.getTopic(123);
await client.createPost(123, "Hello from the widget");
await client.likePost(456);

No init() call to load the WASM runtime. No BigInt() wrappers. Just regular async functions.

Updating discourse-comments

With the new client published to npm, updating the web component was mostly find-and-replace:

The build script went from 60 lines of WASM gymnastics to a straightforward esbuild call:

await esbuild.build({
  entryPoints: ["src/discourse-comments.ts"],
  bundle: true,
  format: "iife",
  outfile: "dist/discourse-comments.min.js",
  minify: true,
});

No custom plugins. No base64 encoding. No WebAssembly.instantiate.

The Numbers

Rust/WASMTypeScript
Bundle size (minified)742 KB18.5 KB
Runtime dependencieswasm-bindgenNone
Build stepscargo build + wasm-pack + tsc + esbuildtsc + esbuild
Init overheadWASM decode + instantiateNone

That 18.5 KB includes the entire web component, all the styles, the OAuth flow, and the API client. A user on a slow connection downloads 40x less data.

Dev Experience

With the Rust client, the build pipeline was: cargo build targeting wasm32, wasm-pack to generate JS bindings, TypeScript compilation, then esbuild with a custom plugin to inline the binary. If any step failed, the error could be in Rust, in wasm-bindgen, in the TypeScript types, or in the bundler plugin. Debugging meant jumping between ecosystems.

With TypeScript, it's tsc && node build.mjs. If there's a type error, it's in TypeScript. If there's a runtime error, it's in JavaScript. One language, one toolchain, one set of error messages.

Adding a new API method in Rust meant: define the struct, implement the method, rebuild WASM, regenerate bindings, update TypeScript types to match. In TypeScript: add an interface, add a method, done. I added search, user profiles, and topic management (6 new methods) in one sitting.

What I Lost

Honesty requires noting what the WASM version had going for it.

Rust's type system caught entire categories of errors at compile time. The TypeScript version uses interfaces, which help, but they're structural and optional. A Post with a missing field compiles fine until it blows up at runtime.

Rust's Result type forced explicit error handling everywhere. In TypeScript, it's easy to forget a .catch() or let an error propagate silently.

For this particular project, those trade-offs don't matter much. The API surface is small and the error handling is straightforward. But for a larger client library, I'd think harder about it.

When WASM Makes Sense

WASM is the right call when you need computation that JavaScript can't do efficiently: image processing, cryptography, physics simulations, codecs. If you're doing heavy lifting in a tight loop, WASM's predictable performance model wins.

But for HTTP clients that serialize JSON and build URLs, the browser already has everything you need. Adding a WASM layer for that is carrying a backpack full of bricks on a walk to the mailbox.

Try It

The widget still works the same way. Drop it on a page, point it at a Discourse topic, get comments. It's just 40x lighter now.

<script src="discourse-comments.min.js"></script>
<discourse-comments
  discourse-url="https://forum.example.com"
  topic-id="123">
</discourse-comments>