Rebuilding My Bahasa Dictionary, and Measuring Node, Bun and Go on It

My Bahasa and English dictionary, rebuilt from the raw WordNet files with word frequency and word families added, then compared as a SvelteKit site run on Node and on Bun and as a Go server, to see which one wastes the least memory on a small server.

Published by Sudhanshu on Oct 13, 2026

A dictionary is a lookup table that never changes, and I was running mine as a Go API, a Postgres database and a separate web app.

I built the first version years ago, and the static site generator post already admits how that went: I wrote the front end in Svelte and then forgot everything about it. When I finally went back to it, the rebuild came first. The bigger question came after: does a dictionary that never changes need a database server at all? The data comes from two open datasets, nothing is ever written, and the server is small and shared.

I kept the rule that nothing gets deployed until I say so, and the result is live as the dictionary.

What a dictionary is made of

The data is Princeton WordNet 3.0, which groups English words into sets that mean the same thing (117,659 of them, called synsets), plus Wordnet Bahasa, which attaches Indonesian and Malay words to those same sets. Every link carries a quality grade, and the site only serves the best two grades.

The old importer pulled this through Postgres. The new build reads the raw files directly and writes one SQLite file: 861,324 word rows, 189,585 distinct words and phrases served, 36,553 of them Bahasa, about 83 MB in total, in about nine seconds. No database server, no API.

Rebuilding it also turned up a bug in the old importer. WordNet identifies a meaning by its byte offset inside a file, and there is one file per part of speech, so the same number can mean two different things. The old code keyed meanings by offset alone and quietly dropped the second one. So some words showed another word's synonyms: the old site listed "acetabular" and "cotyloid" as synonyms of "sauce". The new build keys on offset plus part of speech, and was compared against the old API on 304 words: 150 drawn from the new build's own word list, 150 from the English word list that ships with macOS, and four fixed ones. 298 of the 304 matched on meanings and synonyms, though 115 of those are words neither version has an entry for, so 183 are real matches. The 6 that differed: in four the old site had extra synonyms (for sauce, anatomy terms, traced to this bug), and in two the new build has a synonym the old one lacks. The other five were not checked one by one.

Making the page say more than "big"

The old page for besar listed 28 senses ordered only by link quality, including "a book written by Karl Marx", followed by two long alphabetical lists of related words.

One of the 28 meanings the old page listed for besar was "a book written by Karl Marx".

I had asked whether there was a better way to render that, so the page now groups senses by part of speech, orders them by how reliable the Bahasa link is and then by how common the sense is in English, shows three per group and folds the rest away. The lower of the two served grades is marked as less certain instead of hidden.

I had also asked for each word to carry its frequency in Bahasa, and my Bahasa project already had a text corpus, the one my vocabulary deck is generated from, so its word counts gave me that for free. Besar is rank 62 out of 1,175,854 words, 7,630,520 occurrences. The home page now shows twelve random words from the top 1,000; 827 of those 1,000 have meanings in the dictionary.

Word families are the most Bahasa-specific part. Bahasa builds words from a root: besar becomes membesarkan, kebesaran, pembesaran. A rule-based stemmer links those forms to the root. It handles the common prefixes, suffixes and sound changes, like tulis becoming menulis. It only accepts a root that is a real Wordnet Bahasa word and common enough to be plausible, and follows chains down to the base, so membesarkan ends at besar and not at membesar. That gave 76,042 linked forms across 10,063 roots.

It over-stripped at first, turning tersebut into but and tersedia into dia. Refusing to follow the chain down to a root shorter than four letters fixed those, but serangan still ends up at rang. The stemmer's roots were set against my deck's roots for the 9,688 deck words (of 22,151) that are plain letters and have a root different from the word itself; the 628 hyphenated words, such as orang-orang, were left out. The stemmer agrees with the deck on 6,755 of those 9,688 (about 70%), gives a different root on 937 (about 10%), and gives none for the other 1,996 (about 21%). That is agreement on the deck's derived words only; its answers on the words the deck treats as roots were not measured at the final version. The disagreements go both ways: for menurut the deck is wrong (it says urut; it comes from turut), and for serangan the stemmer is. I have not gone through the rest.

Where do you put an 83 MB read-only file

There were four options.

A Cloudflare Worker with D1. It works, and reads are effectively free. The import is the catch: about two million row writes against a free plan that allows 100,000 a day, enforced since 1 September. That is roughly twenty days, or the five dollar plan for one month.

Static shards. Precompute every page's data (an estimated 300 MB, never built) into around 10,000 shard files under the free plan's 20,000 file limit, and let a Worker read one per request. It would work and cost nothing, but I called it too much drama and said no.

A small API on my server with Postgres, and the web on Cloudflare. This one was my own suggestion, since I had the space anyway. Two network hops per page, a public API to defend, and a manual data load every time.

One container with the SQLite file inside it. This was the first thing I had asked about, and the one I came back to. One process, no network hops, Cloudflare's proxy in front for caching.

I usually use Pages, so I asked how Workers differ in functionality and cost. Checking Cloudflare's documentation corrected two earlier answers. The first reversed a lean towards Pages: they cost the same, static asset requests are free on both, and Cloudflare's own documentation now says to start new projects on Workers. The second is about caching: a response from a Worker is not cached at the edge just because it sends s-maxage; you have to turn on Workers Cache.

One SQLite file in one small container was enough for a dictionary that never changes.

Node, Bun or Go

My one worry about the container was memory. The server is small and shared, and I usually prefer Go anyway. I said to build it in Go, and Claude built the Go server next to the SvelteKit version it had already built. Then I asked for the two to be compared, so a throwaway copy of the SvelteKit version was built for production, with the Cloudflare adapter swapped for the Node one and D1 swapped for each runtime's built-in SQLite, and run under Node and under Bun, and measured against the Go server.

The Go server rendered the same pages from embedded templates, with a pure-Go SQLite driver. All three read the same 83 MB database read-only and got the same load: 4,500 words drawn at random from the frequency table and requested as pages, in three bursts of 1,500, eight at a time. That table holds 88,778 words, and 76,042 of them are derived forms like membesarkan, so most of the pages are small: in a later replay of the same kind of draw on the final database the median page was about 5 KB, against about 42 KB for besar. Private memory (what the process actually owns) was measured with macOS's footprint, because RSS also counts database pages the operating system maps in and can drop at any time: Go showed 18 to 95 MB of RSS, against 16 MB of private memory.

Fresh start After 1,500 pages After 3,000 After 4,500 Idle 5s later
Go 11 MB 16 MB 16 MB 16 MB 16 MB
Bun 41 MB 80 MB 71 MB 77 MB 61 MB
Node 33 MB 149 MB 134 MB 140 MB 141 MB

The Fresh start column and the three timings further down come from an earlier single burst of 1,500 pages; Fresh start came after two requests for one page. Bun read 64 MB after the burst instead of 80, Node 150 instead of 149 and Go 15 instead of 16. The other columns come from the three-burst run, which recorded no fresh start. The Go server also ran with SQLite memory mapping on (up to 256 MiB); the Node and Bun copies were opened without it.

Same site, same 83 MB database, same 4,500 requests: Go held 16 MB with SQLite memory mapping on; Bun 61 to 80 MB and Node about 140 MB, both without it.

None of them kept growing over the three bursts: Node and Bun levelled off after the first, and Go did not move. Go was also the quickest: 1,500 pages took 2.9 seconds, against 3.9 on Bun and 4.2 on Node. With memory mapping off, Go's private memory after a single burst of 1,500 pages was 46 MB, against 64 MB for Bun and 150 for Node.

The Go server's pages matched the Svelte ones on the visible text of 109 words (60 random served ones, 40 common ones, 9 fixed), with one difference: in the 20 pages with a "from this root" line, the Svelte version was missing a space after the affix label, as in "(ke-…-an)". The Go lookups are also pinned to the TypeScript ones with a file of 20 expected answers, so a change to any of those 20 answers on the Go side fails a test.

The Go server's container image is about 96 MB, most of it the database. It ran on a read-only filesystem with a 128 MiB cap, hit with a thousand pages, eight at a time, and stayed healthy. The container's counter read 126 of 128 MiB afterwards, which includes cached pages of the database file, so it is not the 16 MB figure above.

I went with Go. The cost is real: the design lives in Go templates, not Svelte components, and the data build stays in TypeScript, so one folder holds two languages. It was also measured on my Mac, not on the server, and on a load of mostly small pages, so the absolute numbers will move.

What isn't done

It is live now, as one container on the same small server, and the old API and its Postgres database are gone. There is no nightly backup, on purpose: the database never changes and is rebuilt from the raw files in about nine seconds, so the thing worth keeping is the raw files, once. The frequency corpus is the frequency list my deck already uses, and I have not traced where it came from, so I do not know whether I can credit it. The Wordnet Bahasa links are noisy: the first verb sense for membesarkan is still "add details, as to an account or idea", and I have not tried to fix that.

It is a better dictionary than the one I started with, and the Go process holds 16 MB under load on my Mac, with memory mapping on. About 30% of those 9,688 deck words either get a different root from the stemmer or none at all, and I have not worked out which side is right.