Two beliefs that parked my Go rewrite. Both were wrong.

First published in my LinkedIn newsletter. Mousing has since moved to Go in production.
Last year I started a Go/Lambda rewrite of my SaaS API. Got it about 60% feature-complete. Then parked it.
The two reasons I parked it felt solid at the time:
- Go's document-rendering libraries are too weak for what I need.
- Rewriting all the database adapters by hand will take forever.
When I came back to the spike last week, both turned out to be wrong. Not "kind of wrong" — wrong in ways I should have caught earlier if I'd spent another afternoon on each.
This post is about what I was wrong about, what the actual answers were, and the meta-lesson I want to remember next time I'm tempted to park a spike on a single library-shaped objection.
Belief 1: "Go can't render documents."
Mousing's core pipeline takes a PDF or DOCX, rasterizes each page to an image, and sends those images to OpenAI's vision API for extraction. On .NET, I use Docnet.Core — a wrapper around the pdfium library Chromium uses to render PDFs.
When I looked at Go's options the first time, I saw go-fitz (a MuPDF binding) and immediately had two problems:
- It uses cgo, which means I can't build a static Go binary. Lambda deploys get pushed from "upload a zip" to "build and push a container image" — a step up in operational complexity I didn't want.
- MuPDF is AGPL-licensed. For commercial SaaS, AGPL is a serious legal problem — anyone interacting with the service over a network can demand source code.
That was enough to park the spike. I didn't keep looking.
What I missed: github.com/klippa-app/go-pdfium exists, has a WebAssembly backend that runs the same pdfium engine .NET uses, via pure-Go wazero — no cgo. Static binary. BSD-licensed. Bundled pdfium.wasm ships with the package.
The actual implementation:
- Rasterize at ~150 DPI (matches .NET's default; 300 DPI quadruples your vision-token cost for printed text and you don't get meaningfully better OCR)
- The wasm instance is single-threaded — initialize once, serialize access via a mutex-protected singleton or a small pool
That's it. The whole "Go can't render documents" objection dissolved in about an hour of looking again.
The pattern worth keeping: when porting a .NET imaging or media pipeline to Go, before concluding "Go can't," look for a wazero-backed binding of the same underlying native library. wazero is mature enough now that this is increasingly the answer to "how do I get this C/C++ library into a static Go binary."
Belief 2: "Hand-writing all the database adapters will take forever."
Each feature in the .NET stack hits the database directly via Entity Framework. The Go spike's handlers had been written assuming each feature would declare a local DB interface with its own row and parameter types — but the actual SQL execution layer underneath that interface was never wired up.
I had a mental model that filling in all 15 features' adapter layers would be tedious sequential work — generate the queries, write the mapping, plumb it through, repeat 15 times.
The actual pattern that worked:
One: Generate the SQL execution code once with sqlc, configured so nullable columns become standard Go pointer types (*string, *uuid.UUID) instead of sqlc's default wrapper types. That one config tweak removed most of the type-conversion friction that would otherwise be everywhere.
Two: One shared helper package with the small conversion utilities every adapter needs — context handling, "not found" checks, nullable conversions. Every feature's adapter reuses it instead of re-deriving these from scratch.
Three: Per feature, a single file in that feature's own package. That file holds a small store type wrapping the generated queries, plus the mapping between generated row types and the local types the feature exposes.
Because each feature owns its own adapter file in its own package, every feature is parallelizable. I fanned out 15 features to subagents working in parallel. Zero merge conflicts. The only files I owned as the lead were the shared ones: the router, the composition root, the helper package.
That took a day. Not a week.
The pattern worth keeping: make your per-feature adapter layer live in the feature's own package, not in a shared infrastructure layer. Shared infra layers serialize the work — everyone's editing the same files. Per-feature adapter packages parallelize it.
The other things that bit me
Two smaller gotchas worth flagging for anyone doing similar work:
The chi-double-mount panic. The Go router I'm using, chi, panics on startup if you accidentally register the same path prefix in two places via its Route and Mount patterns. Caught me with a /billing mount where the Stripe webhook (no auth) needed to be split out from the authenticated billing routes. The route registration looked clean to the eye but crashed on every cold start. The fix is to register unauthenticated paths explicitly on the root router and put authenticated ones inside the group — never mount the same prefix twice. Easy to miss until it crashes live.
Batch tracking doesn't survive Lambda. The .NET stack tracks in-flight batch extractions in an in-memory dictionary. Fine on a long-lived server. On Lambda, every invocation is potentially a fresh container — that dictionary doesn't persist. The right fix wasn't to fake an in-memory cache on Lambda. It was to add a BatchId column to the database and look up batch state there. This isn't a parity violation. It's the correct serverless adaptation.
One binary, two run modes. A small thing that paid off: a single Go binary that becomes a Lambda handler when AWS_LAMBDA_FUNCTION_NAME is set in the environment, and a normal HTTP server otherwise. Same code path, different entry point. Local development runs the same binary as production deploys, which removes a whole category of "works locally, broken in Lambda" debugging.
The meta-lesson
I parked this spike on two objections that, in hindsight, were thin.
The "Go can't render documents" objection was a 30-second search away from being resolved. I didn't do that search because the objection felt solid enough at the time.
The "hand-writing adapters will take forever" objection assumed sequential work. It was actually trivially parallel work that I was modeling sequentially.
Both objections shared a structure: they were beliefs I'd formed during a frustrating first pass at the spike, and they hardened into reasons-not-to-revisit. The next time something I think I want to build feels stuck, the question I want to ask is "is this stuck because the work is genuinely hard, or because I formed an opinion last time that I haven't tested since?"
Most of the time, the answer is the second one.
The Mousing Go rewrite still isn't deployed. The migration decision is still about hosting and cost, not technical feasibility. But the spike isn't parked anymore — and the two reasons I parked it in the first place don't apply.
That's the value of unparking. Sometimes the work is easier than your past self decided.





