A user types 64+5 into Wrongulator, gets back 67 — "the only correct number" — laughs, and pastes the link into a group chat. Twitter, WhatsApp, Slack, Discord: each one fetches the URL, finds an Open Graph image, and unfurls a 1080×1080 card right there in the feed. That card is the joke. If it looks even slightly different from the card the user saw in their browser — different font, missing emoji, wrong line break — the joke breaks in transit.
The share card is the unit of virality, so it has exactly one job: be byte-for-byte the same picture wherever it renders. The problem is that "wherever it renders" means two completely different environments — a browser <canvas> and a Node process — and the obvious way to support both is to write the card twice. That's the trap. This post is about isomorphic canvas rendering: writing one drawing routine and running it unchanged in both runtimes, so the two pictures can't drift because there's only one of them.
Why You Need a Server Render at All
The instinct for a client-side toy is to keep everything in the browser. The engine that computes Wrongulator's wrong answer does exactly that — it's a pure function, no server call, and why the same input always returns the same wrong answer is its own story. So why involve a server in the picture at all?
Because social crawlers don't run JavaScript.
When you paste a link into a chat app, the crawler that fetches it — Twitterbot, facebookexternalhit, Slack's unfurler — reads the raw HTML response and stops. It does not boot a JS runtime, it does not wait for <canvas> to paint, it does not execute your engine. It looks for <meta property="og:image"> and fetches whatever URL it finds. That means the 1080×1080 PNG has to already exist in the server's response, fully rendered, before a single line of client JS runs.
So the card lives a double life. In the browser, it's painted live on a real <canvas> element the moment the user lands on a result. On the server, the same card has to be produced as a static PNG by an endpoint a crawler can fetch — no browser, no DOM, no JS execution on the crawler's side. Two environments, one picture, and they have to match.
The Two-Renderer Trap
The naïve way to satisfy both is to build two renderers. A browser one that draws to <canvas>, and a server one that produces the OG image somehow — maybe an SVG template, maybe an HTML-to-image library, maybe a headless browser screenshotting the page.
Every one of those splits your card into two sources of truth. The browser renderer says the headline sits at y: 120 with 48px Russo One; the SVG template says y: 118 because SVG text baselines work differently. The browser wraps the reason at 40 characters; the server library wraps at 38 because it measures glyphs differently. None of these are bugs, exactly — they're two implementations of "the same" layout that were never going to agree on every pixel. And the drift compounds: every time you tweak the card in one place, you have to remember to mirror it in the other, forever. Miss one, and the shared image quietly diverges from the one users see.
For a product whose entire growth loop is "the picture I shared is the picture you see," that drift isn't cosmetic. It's the failure mode.
The fix is to refuse the premise. Don't write two renderers that try to agree. Write one renderer that runs in both places.
One UMD Core, Two Runtimes
Canvas 2D is the right common denominator because both worlds speak it. The browser gives you a CanvasRenderingContext2D from a real <canvas>. On the server, @napi-rs/canvas — a Rust-backed, Skia-powered Node binding — gives you a context with the same API surface: fillRect, fillText, measureText, drawImage, the lot. If you write your drawing code against that bare API and touch nothing browser-specific (no document, no window, no DOM measurement), the exact same function runs in both.
So the card is a single module, card-core.js, written against the Canvas 2D API and exported as UMD so it loads cleanly in either environment:
// public/card-core.js — environment-agnostic UMD; same draw() in both runtimes
(function (root, factory) {
if (typeof module === "object" && module.exports) module.exports = factory();
else root.WrongCardCore = factory();
})(typeof self !== "undefined" ? self : this, function () {
// ... drawCard(ctx, { expr, answer, reason, headline, emoji })
});
The UMD wrapper is the whole trick. In Node, module.exports exists, so the factory's return value becomes the module — require('./public/card-core.js') gives you { W, H, drawCard }. In the browser, there's no module, so it hangs the same object off self as WrongCardCore, and a <script src="card-core.js"> tag makes it global. One file, zero build step, no bundler required, and critically: the drawCard function inside is identical in both cases because it's literally the same source.
The browser side feeds it a real canvas context. The server side feeds it a Node canvas context — and that's the entire OG endpoint:
// server.js — the OG endpoint feeds the identical core a node canvas
app.get('/api/og', async (req, res) => {
const canvas = createCanvas(core.W, core.H);
const r = wrongulate(expr, tone, lang);
core.drawCard(canvas.getContext('2d'), { expr, answer: r.answer, reason: r.reason, ... });
res.setHeader('Cache-Control', 'public, immutable, max-age=31536000'); // output is deterministic
res.end(canvas.toBuffer('image/png'));
});
Read that endpoint and notice what it doesn't do. It doesn't lay out text. It doesn't pick colors, position the headline, or wrap the reason. All of that lives in core.drawCard, the same function the browser calls. The server's job shrinks to three lines: make a canvas of core.W × core.H, hand the context to the shared core, serialize the result with canvas.toBuffer('image/png'). The dimensions come from the core too (core.W, core.H), so even the canvas size can't drift between environments.
This is the payoff of isomorphism stated plainly: the OG endpoint has no rendering logic of its own to get wrong. There's nothing to keep in sync because there's nothing duplicated. When I change how the card looks, I change one function, and both the in-app card and every future unfurl move together.
slug="mvp-development"
text="If your MVP's growth depends on shareable artifacts — cards, certificates, generated images that unfurl in feeds — the server render is not an afterthought. I build the whole loop: engine, isomorphic render, OG endpoint, caching."
/>
The Font Problem: Where Isomorphism Actually Costs You
I want to be honest about where this approach gets hard, because "one function, two runtimes" makes it sound free. It isn't. The seam shows up in fonts.
In the browser, font fallback is automatic and invisible. You ask for a font, and if a glyph isn't in it — say the user's reason contains Japanese and an emoji — the browser silently walks its own fallback chain and finds something that can render サ and 🙏. The OS has fonts installed; the browser knows how to reach them. You never think about it.
A Node process has none of that. @napi-rs/canvas will only draw glyphs from fonts you have explicitly registered, and it will not invent a fallback chain for you. Ask it to render "サンキュー 🙏" with only a Latin font loaded and you get tofu boxes, or nothing, where the Japanese and the emoji should be. The browser hid an entire subsystem from you, and on the server you have to rebuild it by hand.
Wrongulator runs in 17 languages, including RTL Arabic, so a card's text can mix Latin, Cyrillic, Thai, Arabic, Japanese, Korean, and emoji in a single string. To make that render server-side, the OG renderer carries a per-glyph font fallback chain: Russo One for Latin and Cyrillic, the appropriate Noto faces for Thai, Arabic, Japanese, and Korean, and Noto Color Emoji for the pictographs. Each of those font files has to be present in the runtime, so they're fetched at build time — the Dockerfile pulls the OFL-licensed fonts and bakes them into the image, registering each with the canvas library before any card is drawn. That's real work the browser did for free, and it's the part of "isomorphic" that has an asterisk.
Determinism Makes the Cache Free
Here's where the rendering story meets the engine story, and the two reinforce each other.
The wrong answer for 64+5 is a pure function of 64+5. It's 67, forever, for everyone — that determinism is the whole reason the toy is shareable. Now layer the isomorphic render on top: because the card is drawn by a pure function of the same inputs, the PNG for /api/og?expr=64+5 is also a pure function of its query. Same query in, same bytes out, every time.
Which means the image never needs to be re-rendered. So the endpoint sets:
res.setHeader("Cache-Control", "public, immutable, max-age=31536000");
A full year, marked immutable. The CDN fetches each card once and serves it from the edge forever. There's no invalidation logic, no cache-busting, no "did the source change" check — the output can't change for a given input, so caching it permanently is not a risk, it's the correct behavior. Determinism plus isomorphism turns the OG endpoint from a per-request renderer into a write-once asset factory. The first crawler to unfurl 64+5 pays for the render; every subsequent one, across every platform, gets a CDN hit.
That's the combination I want to underline: a deterministic engine alone gives you a cacheable answer; an isomorphic render gives you a cacheable picture that's guaranteed to match what users see. Together they make the share card free to serve at scale.
The Honest Edge Cases
"Byte-for-byte identical" is the goal and the day-to-day reality of this design, but I'd be lying if I called it a mathematical guarantee across every platform. The browser's Canvas implementation and @napi-rs/canvas are different engines — Skia under the Node binding, the browser's own compositor in front. Antialiasing can differ at the sub-pixel level. Text metrics from measureText can disagree by a fraction, which on a long wrapped line can occasionally push a word to the next row in one environment and not the other.
So what does isomorphism actually buy if it's not a bit-exact promise? It removes the structural source of drift — the second renderer with its own layout opinions. Both environments run the same drawCard against the same Canvas 2D API with the same fonts and the same dimensions, so they agree on everything the code decides: positions, sizes, colors, wrap points computed from the same measureText logic. What's left to disagree on is the rendering engine's own pixel-level rasterization, and that's a far smaller, far more bounded gap than "two people wrote two layouts." In practice, on the platforms that matter, the cards are indistinguishable. The mitigation is the architecture: one function, one API, one set of inputs.
A few reasonable alternatives, and why I passed on them:
- Satori / SVG-to-image. Satori is excellent for OG images, but it renders a subset of CSS flexbox to SVG — it's a different rendering model from Canvas 2D. Adopting it for the server would mean the in-browser card (Canvas) and the OG card (Satori) are two renderers again, with the exact drift I was trying to kill. The whole point was one routine; Satori reintroduces two.
- Headless browser screenshots. Spinning up Chromium per OG request would give pixel-perfect parity with the browser card — at the cost of hundreds of megabytes of runtime, slow cold starts, and a fragile dependency for a toy that's meant to ship as one small Docker container. The card is a few shapes and some text; paying for a whole browser to draw it is the wrong trade.
- A static SVG template. Cheapest to render, but SVG text layout, wrapping, and font handling diverge from Canvas, and you're back to maintaining a second layout that has to chase the first.
@napi-rs/canvas won because it's the only option that lets the server run the same code as the browser. Every alternative trades that away for something else.
Results
| Metric | Value |
|---|---|
| Renderers | 1 — single drawCard() routine, run in browser and on the server |
| Module format | UMD (card-core.js) — module.exports in Node, global in the browser |
| Server canvas |
@napi-rs/canvas — Skia-backed, same Canvas 2D API as the browser |
| OG endpoint logic | 3 lines — create canvas, call shared core, toBuffer('image/png')
|
| Card size | 1080×1080 PNG, dimensions from core.W / core.H (single source) |
| Font fallback | Per-glyph chain — Russo One, Noto (Thai/Arabic/JP/KR), Noto Color Emoji |
| Fonts | OFL faces fetched at build time, baked into the Docker image |
| OG image cache |
immutable, max-age=31536000 — deterministic output never re-renders |
| Runtime deps | 3 total (Express, @napi-rs/canvas, ioredis) |
| Deploy | Single Docker container on Coolify |
Takeaways
If two outputs must match, generate them from one source. The reliable way to make the browser card and the unfurl card identical isn't to carefully keep two renderers in sync — it's to have one renderer. Any time you find yourself mirroring layout logic across environments, ask whether a shared routine can replace both.
Pick the API both runtimes already speak. Canvas 2D works in the browser and, via
@napi-rs/canvas, in Node. Writing against that bare common API — and nothing environment-specific — is what makes the same function portable. UMD packaging is the small glue that lets one file load in both places with no build step.The browser hides whole subsystems; the server makes you rebuild them. Font fallback is the classic example. What's automatic and invisible in a browser becomes an explicit per-glyph fallback chain plus a build step to ship the font files. Budget for the seams — isomorphism removes duplicated logic, not duplicated environments.
Determinism and isomorphism compound. A deterministic engine gives you a cacheable answer; an isomorphic render gives you a picture that provably matches what users see. Together they let you cache the OG image
immutablefor a year — write once, serve from the edge forever."Identical" is an architecture, not a guarantee. Two rendering engines can still differ at the antialiasing and text-metrics level. You can't eliminate that by writing more code — you eliminate the structural drift by running one routine against one API with one set of inputs, and accept that the residual gap is bounded and, in practice, invisible.
The full project — the deterministic Wrong Engine, 17-language i18n, server-side per-link SEO, and a spoof-proof Hall of Fame — is written up in the Wrongulator project card. For the engine half of this story, see why the wrong answer is the same every time.
Top comments (1)
This is neat! How do you handle font loading and availability differences between the browser and Node environments with