RSS aggregator JS Ecosystem News
Live feed0 visits
3 today 12 unread
Lead story dev.to 31 minutes ago Fresh today Unread

From a modular monolith to microservices without a rewrite

Most "monolith to microservices" stories go one of two ways. Either it's a rewrite that takes eighteen months and ships a system nobody asked for, or it's a big-bang split into fifteen services that immediately need a Kubernetes team. Both come from the same mistake: treating "process boundaries" and "module boundaries" as the same decision.
They aren't. You can draw the module boundaries now, inside the monolith, and move the process boundaries later — one service at a time, when you have a reason to. This article shows exactly that with a small Express app: three stages, the same service files in every stage, and real output at each step, including the one thing that does break when you finally run two copies of a service.
The starting point
A perfectly ordinary Express monolith. Three modules, wired together with require(), one process.

// lib/orders.js — calls users and mailer through plain imports. Tight coupling, zero ceremony. const users = require("./users"); const mailer = require("./mailer"); let counter = 0; const orders = []; exports.create = ({ userId, item, amount }) => { const user = users.get(userId); const order = { id: ++counter, userId, item, amount }; orders.push(order); mailer.send(user.email, `Order #${order.id} confirmed (${item})`); return order; };
// app.js const express = require("express"); const users = require("./lib/users"); const orders = require("./lib/orders"); const app = express(); app.use(express.json()); app.get("/users/:id", (req, res) => res.json(users.get(Number(req.params.id)))); app.post("/orders", (req, res) => { const userId = Number(req.header("x-user-id")); res.status(201).json(orders.create({ userId, ...req.body })); }); app.listen(process.env.PORT || 3000);
$ curl -s localhost:3000/users/1 {"id":1,"name":"Ada","email":"ada@example.com"} $ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 2' -d '{"item":"Monitor","amount":329}' {"id":1,"userId":2,"item":"Monitor","amount":329}
Nothing wrong with this. It's the right architecture for a team of three with one deployable. The problem only starts when orders grows a queue consumer, mailer needs to scale independently because a marketing campaign sends 200k emails, and you realise nothing in the codebase says which module is allowed to call which.
Stage 1: module boundaries become service boundaries — same process
Bring in a service broker (Moleculer's ServiceBroker — think of it as an in-process service runtime with a registry) and turn each module into a service: a plain object with a name and actions (its callable endpoints). No transporter is configured, so every call is an in-memory function call. No network, no serialization, no new infrastructure.

// services/orders.service.js — lib/orders.js as a service. // No require("./users") any more: the dependency goes through the broker, and is declared. let counter = 0; // module-level state — fine in a monolith; see "What breaks" below const orders = []; module.exports = { name: "orders", dependencies: ["users"], // broker waits for `users` before starting this service actions: { create: { params: { userId: { type: "number", convert: true }, item: "string", amount: { type: "number", positive: true }, }, async handler(ctx) { const user = await ctx.call("users.get", { id: ctx.params.userId }); const order = { id: ++counter, userId: user.id, item: ctx.params.item, amount: ctx.params.amount }; orders.push(order); await ctx.emit("order.created", { order, user }); return { ...order, servedBy: this.broker.nodeID, usersServedBy: user.servedBy }; }, }, }, };
Three things changed and they are all improvements you'd want anyway:
The dependency is explicit. dependencies: ["users"] is documentation the runtime enforces — orders won't start until users is available.
The input is validated at the boundary. params is a schema; a bad amount is rejected before the handler runs.
Mailer is no longer called — it's notified. orders emits order.created; whoever cares subscribes. That's the seam you'll use later to move it out.

// services/mailer.service.js — lib/mailer.js as a service. Instead of being called, it reacts to an event. module.exports = { name: "mailer", events: { "order.created"(ctx) { const { order, user } = ctx.params; this.logger.info(`→ ${user.email}: Order #${order.id} confirmed (${order.item})`); }, }, };
Express stays. The routes call the broker instead of the modules:

// app.js — the same Express app, now with a broker inside. Routes call services instead of modules. const express = require("express"); const { ServiceBroker } = require("moleculer"); const broker = new ServiceBroker({ nodeID: `app-${process.pid}`, transporter: process.env.TRANSPORTER || null, // null = local bus, in-process only logger: { type: "Console", options: { level: "info", formatter: "short" } }, }); const wanted = process.env.SERVICES === undefined ? ["users", "orders", "mailer"] : process.env.SERVICES.split(",").filter(Boolean); for (const name of wanted) broker.createService(require(`./services/${name}.service.js`)); const app = express(); app.use(express.json()); app.get("/users/:id", async (req, res, next) => { try { res.json(await broker.call("users.get", { id: req.params.id })); } catch (e) { next(e); } }); app.post("/orders", async (req, res, next) => { try { const userId = Number(req.header("x-user-id")); res.status(201).json(await broker.call("orders.create", { userId, ...req.body })); } catch (e) { next(e); } }); app.use((err, req, res, next) => res.status(err.code || 500).json({ error: err.name, message: err.message })); broker.start().then(() => { app.listen(process.env.PORT || 3000, () => broker.logger.info(`HTTP on ${process.env.PORT || 3000}`)); });
$ node app.js [20:06:31.508Z] INFO BROKER: Node ID: app-3470689 [20:06:31.549Z] INFO ORDERS: Waiting for service(s) 'users'... [20:06:31.557Z] INFO USERS: Service 'users' started. [20:06:31.557Z] INFO MAILER: Service 'mailer' started. [20:06:32.552Z] INFO ORDERS: Service(s) 'users' are available. [20:06:32.554Z] INFO ORDERS: Service 'orders' started. [20:06:32.554Z] INFO BROKER: ✔ ServiceBroker with 4 service(s) started successfully in 1s. [20:06:32.557Z] INFO BROKER: HTTP on 3000 $ curl -s localhost:3000/users/1 {"id":1,"name":"Ada","email":"ada@example.com","servedBy":"app-3470689"} $ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 2' -d '{"item":"Monitor","amount":329}' {"id":1,"userId":2,"item":"Monitor","amount":329,"servedBy":"app-3470689","usersServedBy":"app-3470689"} # app log: [20:06:34.088Z] INFO MAILER: → linus@example.com: Order #1 confirmed (Monitor)
Same behaviour, same single process, same deploy. servedBy and usersServedBy are the same node because everything is local. You could stop here for a year and still be better off than before: the boundaries are real, the contracts are validated, and nobody can sneak a require("../orders/db") across modules any more.
This is what "modular monolith" should mean in practice — not a folder convention, but boundaries a runtime knows about.

Stage 2: extract one service — the one that actually needs it
Marketing week: mailer needs to scale on its own and must not slow down the request path. So move only mailer out.
Two changes, neither of them in service code. First, give the app a transporter (a message broker — NATS here) and tell it which services to host locally:

$ TRANSPORTER=nats://localhost:4222 SERVICES=users,orders node app.js
Second, start mailer in its own process with moleculer-runner, the framework's CLI service host, pointed at the same services/ folder:

// moleculer.config.js — for services that run OUTSIDE the app process, via moleculer-runner. module.exports = { nodeID: `svc-${process.env.SERVICES || "all"}-${process.pid}`, transporter: process.env.TRANSPORTER || "nats://localhost:4222", logger: { type: "Console", options: { level: "info", formatter: "short" } }, };
$ SERVICEDIR=services SERVICES=mailer npx moleculer-runner --config moleculer.config.js
Now hit the same endpoints:

$ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 2' -d '{"item":"Monitor","amount":329}' {"id":1,"userId":2,"item":"Monitor","amount":329,"servedBy":"app-3470296","usersServedBy":"app-3470296"} # mailer process log: [20:03:06.188Z] INFO MAILER: → linus@example.com: Order #1 confirmed (Monitor)
Look at what happened:
mailer received the order.created event in another process, over NATS. The orders code that emits it didn't change.
ordersusers is still app-3470296app-3470296: in-process. The app is connected to NATS, but the registry's default preferLocal: true routes a call to a local instance whenever one exists. You extracted one service and paid the network cost for exactly one hop — the one you chose.

That's the whole method. The services/ folder is the same in both processes; which process loads which file is a deployment decision, made per environment with two environment variables. Your dev laptop runs node app.js with everything local; staging runs the hybrid; production splits further. Same code.
Stage 3: split the rest, scale one — and meet the thing that breaks
Let's go all the way: users, orders and mailer each in their own process, two instances of orders because it's the hot path, and the app hosting nothing but HTTP:

$ SERVICEDIR=services SERVICES=users npx moleculer-runner --config moleculer.config.js $ SERVICEDIR=services SERVICES=orders npx moleculer-runner --config moleculer.config.js $ SERVICEDIR=services SERVICES=orders npx moleculer-runner --config moleculer.config.js $ SERVICEDIR=services SERVICES=mailer npx moleculer-runner --config moleculer.config.js $ TRANSPORTER=nats://localhost:4222 SERVICES= node app.js $ for i in 1 2 3 4; do curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 1' -d '{"item":"Cable","amount":9}'; echo; done {"id":1,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470323","usersServedBy":"svc-users-3470321"} {"id":1,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470322","usersServedBy":"svc-users-3470321"} {"id":2,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470323","usersServedBy":"svc-users-3470321"} {"id":2,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470322","usersServedBy":"svc-users-3470321"}
The good news: round-robin load balancing between the two orders instances, calls to users over the wire, no code changes — all of it just works.
The bad news is in the first column. Order IDs 1, 1, 2, 2. Remember let counter = 0 at the top of orders.service.js? Each process has its own. In the monolith it was a perfectly good ID generator; with two instances it's a duplicate-key bug that would have shipped.
This is the honest part of the article. Turning modules into services is nearly free. Running two copies of one is where the monolith's hidden assumptions surface. Here is the list I check before scaling any service past one instance:
Module-level state — counters, caches, "the current batch", Maps of sessions. Every instance gets its own. Move it to the database or a shared store, or make it instance-safe (UUIDs instead of counters; here id: crypto.randomUUID() is the one-line fix).
In-memory cache. Moleculer's default Memory cacher is per node; with several instances you'll serve stale data from one and fresh from another. Switch the cacher to Redis — a broker option, not a code change.
Anything that assumes ordering across requests. Two instances process concurrently; if order mattered, you were relying on a single event loop.
Transactions across services. orders + inventory in one SQL transaction worked because they were one process on one connection. Across processes it's either a saga (compensating actions on failure) or you keep those two in the same service. Both are fine; pretending it's still atomic is not.
ctx.meta instead of globals. The request-scoped things you used to keep in req or a module variable (user ID, locale, trace ID) now need to ride along explicitly. Moleculer propagates ctx.meta through every call and event automatically — put them there.

None of these are Moleculer problems, and none of them are avoided by any other framework; they're the actual difference between one process and two. The point of the staged approach is that you hit them one service at a time, with a working system on both sides, instead of all at once on cut-over day.
The migration order that works
If I were doing this to a real codebase:
Stage 1 for everything, no exceptions. Broker in the monolith, every module a service, every cross-module call a ctx.call, every fire-and-forget a ctx.emit. Ship it. It is a refactor with no infrastructure change, and it's where you discover which modules actually depend on which.
Extract the asynchronous ones first. Mailer, image processing, report generation, webhooks — anything that already receives events rather than answering calls. They have no callers to break and their latency doesn't sit on the request path.
Extract what needs to scale or deploy independently. Usually one or two hot services. Run through the "what breaks" list before starting the second instance.
Leave the rest together. A modular monolith with two extracted services is a legitimate end state, not a half-finished migration. Every boundary you didn't turn into a network hop is one that can't fail at 3 a.m.

And keep node app.js — the everything-local mode — working forever. It's how a new developer runs the whole system on a laptop with no Docker, and it's how you run integration tests without a message broker. The local bus is a feature, not a stepping stone.
Code in this article was run on Moleculer 0.15.2, Express 5 and Node.js 22, with NATS 2 for stages 2 and 3. The moleculer-examples repository has a run.sh that reproduces all three stages — including the duplicate-ID bug — in one go.
Sep 22, 2026, 1:00 PMSignal 5
Open article
dev.to5 hours ago
Unread Today

Connection pooling is a software development technique used to maintain a cache of database connections tha...

Open
dev.to7 hours ago
Unread Today

TL;DR I ended up choosing a hybrid: Next.js 14 for static content pages + a separately lazy-loaded client s...

Open
medium.com14 hours ago
Unread

Practical lessons on performance, scalability, rate limiting, caching, databases, background jobs, reliabil...

Open

Live feed

Latest ranked updates

1 / 8
dev.to 2 days ago Unread
Your Nuxt page looks perfect. "View Source" shows clean, fully-rendered HTML — the hero text, the product price, the footer, all there before a single line of JavaScript ran. Then the client bundle finishes loading, and the console lights up: [Vue warn]: Hydration text mismatch. Sometimes it's cosmetic — a number flickers and settles. Sometimes it's worse: a button the user already clicked stops responding, because Vue just tore out the DOM node it was attached to and built a new one.
This is a hydration mismatch, and it's arguably the most Nuxt-specific bug you'll ever debug. It has nothing to do with your logic being wrong in the way a typo is wrong — your component can be perfectly correct JavaScript and still cause one, because the bug isn't in what you wrote, it's in the fact that Nuxt runs what you wrote twice, in two different places, and bets your app's interactivity on both runs agreeing.
This article is written against Nuxt 4.x (verified against the v4.5 release line, August 2026), using the Composition API, auto-imports, and the app/ directory convention Nuxt 4 defaults to. Everything here also applies to Nuxt 3's compatibilityVersion: 4 mode.
What you'll learn
By the end of this article you'll be able to:
Explain exactly what "hydration" means in Nuxt and why a mismatch happens
Recognize the handful of code patterns that reliably cause one
Pick the right fix — onMounted, <ClientOnly>, or data-allow-mismatch — for each situation
Read a hydration warning and know which line of your code to blame
Avoid the "fix" that looks reasonable but guarantees a mismatch every time

Who this is for
You've built at least one Nuxt page with <script setup> and know roughly what server-side rendering means (the server sends back real HTML instead of an empty <div id="app">). You don't need prior SSR debugging experience — that's the point of this article.
Table of contents
The problem: a page that's "correct" and still breaks
The mental model: two renders, one DOM
Fixing it, stage by stage
Edge cases and gotchas
Best practices
FAQ
Cheat sheet
Key takeaways

The problem: a page that's "correct" and still breaks
Say you're building a "tip of the day" widget. It's a plain computed value, no fetch, no state management — about as simple as a Vue component gets:

<script setup> const TIPS = [ "Use useAsyncData for anything that fetches.", "Auto-imports save you the import line, not the thinking.", "Nitro is just Node under the hood.", ] const tip = TIPS[Math.floor(Math.random() * TIPS.length)] </script> <template> <p>Tip of the day: {{ tip }}</p> </template>
Nothing here looks wrong. It compiles, it runs, npm run dev shows a tip. But open the browser console and you'll see something like:

[Vue warn]: Hydration text mismatch: - Server rendered: Tip of the day: Nitro is just Node under the hood. - Client rendered: Tip of the day: Use useAsyncData for anything that fetches.
Nothing crashed. The page still works. But the text the user saw for a split second — the one baked into the HTML the server sent — silently got replaced by a different one the instant the JavaScript took over. If that "tip" were a price, a username, or which item was in stock, this wouldn't be a curiosity, it would be a bug report.
The same failure mode shows up with new Date(), with window.innerWidth, with anything read from localStorage inside the component's render path. The common thread: the value depends on where the code runs, and Nuxt runs your component in two different places.
The mental model: two renders, one DOM
The mental model: Nuxt doesn't render your app once — it renders the same component tree twice, in two different environments, and then asks the second render to adopt the DOM the first render already produced, instead of rebuilding it from scratch.
Here's the sequence for a single page request:
A request hits your server. Nitro runs your Vue app in Node — no browser, no DOM — and walks your components to produce a plain HTML string, plus a serialized payload: the results of every useAsyncData/useFetch call and every useState, embedded in the page as a <script id="__NUXT_DATA__"> block.
The browser receives that HTML and paints it immediately. This is the entire point of SSR — the user sees real content before a single byte of your JavaScript bundle has downloaded.
The client bundle downloads and boots the same Vue app, client-side. But instead of creating new DOM nodes the way a client-only SPA would, it runs in hydration mode: it walks the existing DOM the server produced, node by node, and attaches reactivity and event listeners to what's already there, reading the payload from step 1 so it doesn't have to re-fetch data the server already fetched.

Hydration is a reconciliation, not a second render from scratch — and reconciliation assumes the two renders agree. When they do, hydration is invisible: the DOM stays exactly as the server drew it, listeners attach, the page becomes interactive. When they don't, Vue has two options depending on how badly they disagree:
A text or attribute mismatch (a {{ tip }} that resolved differently, a class that differs): Vue patches just that value in place and — in development only — logs a warning. Production builds do this silently, which is why a mismatch can ship for weeks before anyone notices.
A structural mismatch (a different tag, a different number of children — the kind you get from v-if branching differently on each side): Vue can't patch that in place. It throws away the mismatched subtree and re-renders it entirely client-side. That's real, visible re-work, and if a user had already interacted with something inside that subtree, the element they clicked no longer exists.

The payload exists specifically so that data is safe across hydration — useAsyncData, useFetch, and useState all serialize their results, so the client reads the exact value the server used instead of recomputing it. (If you've read the earlier episode on useAsyncData keys and dedupe, this is the same payload that makes dedupe possible — it's doing double duty.) The danger is everything outside that mechanism: any value your template reads that isn't backed by useState/useAsyncData and isn't guaranteed identical on both sides — Math.random(), Date.now(), window, navigator, localStorage — is a mismatch waiting to happen, because nothing carries it across the server→client boundary for you.
Fixing it, stage by stage
Stage 1: defer the value with onMounted
The tip-of-the-day bug and the "current time" bug are the same shape: a value that's legitimately allowed to differ per visitor, rendered directly during setup. The fix is to give the template a stable, server-safe default, and only fill in the real value once you're certain you're client-side:

<script setup> import { ref, onMounted } from "vue" const tip = ref(null) onMounted(() => { const TIPS = ["Use useAsyncData for anything that fetches.", "…"] tip.value = TIPS[Math.floor(Math.random() * TIPS.length)] }) </script> <template> <p>Tip of the day: {{ tip ?? "Loading…" }}</p> </template>
Key concept: onMounted runs only after hydration has already completed successfully. Anything it writes is a normal, client-only reactive update — Vue never has to reconcile it against server HTML, because by the time it runs, hydration is already done.
Stage 2: skip SSR entirely with <ClientOnly>
Some content isn't "slightly different" between server and client — it can't exist on the server at all. A chart that measures its container's pixel width, a widget that reads localStorage, a third-party embed that expects window. For those, don't try to make the server render something — tell Nuxt not to render it there in the first place. <ClientOnly> is auto-imported and does exactly that:

<template> <ClientOnly> <UserLocalClock /> <template #fallback> <span class="clock-placeholder">--:--</span> </template> </ClientOnly> </template>
The default slot never runs on the server. The #fallback slot renders there instead (useful for reserving layout space so nothing jumps), and the moment the component mounts client-side, Nuxt swaps the fallback for the real content — created fresh, never hydrated.
Key concept: <ClientOnly> doesn't resolve a mismatch — it removes the possibility of one, because nothing inside it is ever compared between two renders. There's only ever one render, on the client.
Stage 3: the branch that looks like a fix but isn't
It's tempting to reach for Nuxt's environment flags — import.meta.server / import.meta.client (the modern replacement for the older process.server / process.client) — and branch your template directly on them:

<!-- Don't do this --> <template> <div v-if="import.meta.client">Client-rendered content</div> <div v-else>Server-rendered content</div> </template>
This guarantees a structural mismatch, every single time. On the server, import.meta.server is true, so the server emits the <div> from the v-else branch. On the client, during hydration, import.meta.client is true, so Vue's hydration walk expects the v-if branch — a different <div> than the one actually sitting in the DOM. Vue can't reconcile two different branches in place; it discards and re-renders. import.meta.client/.server are genuinely useful for deciding what code runs (skip a browser-only import on the server, skip a Node-only one on the client) — they're the wrong tool for deciding what a hydrated template renders, because that decision has to be identical in both places by definition.
Stage 4: when a mismatch is real, expected, and fine — data-allow-mismatch
Occasionally you'll have a value that will always differ by design — a relative timestamp ("posted 3 minutes ago") that keeps ticking, for instance — and you've already accepted that as correct behavior rather than a bug. Vue 3.5 added an attribute for exactly this: data-allow-mismatch silences the hydration warning for a specific element, scoped to the kind of mismatch you name (text, children, class, style, or attribute):

<time data-allow-mismatch="text">{{ relativeTime }}</time>
This only suppresses the console warning — it does nothing to make the values agree. Reach for it after you've decided the mismatch is cosmetic and harmless, never as a first response to a warning you haven't diagnosed yet.
Edge cases and gotchas
Invalid HTML nesting causes mismatches with no logic bug at all. A <div> nested inside a <p>, or malformed <table> markup, gets silently corrected by the browser's HTML parser while it parses the server's HTML — the browser closes the <p> early, restructuring the tree Vue expected to hydrate onto. The fix is markup hygiene, not JavaScript: keep nesting valid per the HTML content model.
Browser extensions mutate the DOM before your JS runs. Grammarly, password managers, and dark-mode extensions routinely inject attributes into the page before hydration starts. These aren't your bug and can't be reliably prevented; data-allow-mismatch="attribute" on the affected element is the pragmatic escape valve once you've confirmed the source.
Server and client timezones differ. A server running in UTC formatting a date directly in a template will disagree with a client in the visitor's local timezone. Same class of bug as Date.now() — same fix: compute the display string in onMounted.
A ref seeded from a browser API at module or setup scope. const isWide = ref(window.innerWidth > 768) throws on the server (there is no window) or, if guarded, still needs a server-safe default and a client-side correction — the same onMounted pattern applies.
Shared server state is a related but different bug. If your mismatch is about the wrong user's data appearing rather than a timing difference, that's the cross-request state leak, not a hydration mismatch — see the earlier episode on useState vs. a plain ref if that's the symptom you're chasing.

Best practices
Ask one question of every render-affecting expression: given the same props and payload, does this produce the exact same output on the server and the client? If the honest answer is "no," it doesn't belong directly in the template.
Default first, correct in onMounted. Any value that's allowed to differ per visitor gets a server-safe placeholder and a client-side update after mount — never a direct read of a browser API during setup.
Reach for <ClientOnly> for whole widgets, not individual values. If an entire component only makes sense in a browser (canvas-sized charts, window-dependent libraries), don't fight it into an SSR-safe shape — skip SSR for it.
Never branch a hydrated template's markup on import.meta.client/.server. Use those flags to decide what code runs, not what a hydrated component renders.
Lint your markup. Invalid HTML nesting is an easy, boring source of mismatches that a markup or accessibility linter catches before it ever reaches a browser.
Test against a production build, not just nuxt dev. Run nuxt build && nuxt preview before shipping something that touches SSR — dev's warnings are the same, but dev's timing can mask issues that show up under real hydration.

FAQ
Does a hydration mismatch crash my app?
No — Vue reconciles it either way. A text/attribute mismatch is patched in place; a structural one is discarded and re-rendered client-side. The app keeps working, but a structural mismatch means real extra work and a possible flash or loss of state in that subtree.
Why does the warning only appear in development?
Vue's hydration mismatch console warning is a development-only diagnostic. In a production build, the same reconciliation happens, but silently — which is exactly why these bugs can ship unnoticed for a long time. Always sanity-check SSR-sensitive pages against a nuxt preview build, not just dev.
Is <ClientOnly> the same thing as checking import.meta.client?
No. import.meta.client is a compile-time flag that decides which lines of code are included in which bundle — it's a build-time tool. <ClientOnly> is a runtime component that skips server rendering for its slot content and mounts it fresh in the browser. Using the flag to branch a hydrated template's markup causes the exact mismatch this article is about; <ClientOnly> avoids it by never hydrating that content at all.
Does useState prevent hydration mismatches?
It prevents the specific class caused by state disagreeing between server and client, because its value is serialized into the payload and read identically on both sides. It doesn't protect a value your template computes independently of useStateMath.random() inside a <script setup> block is still a mismatch even if an unrelated useState call exists elsewhere in the same component.
Can a mismatch happen even when my code is completely correct?
Yes. Third-party scripts and browser extensions can alter the DOM before your app hydrates, and that's outside your code's control. data-allow-mismatch on the specific affected attribute is the accepted mitigation once you've confirmed that's the cause.
Cheat sheet
Situation Symptom Fix Math.random() / Date.now() read during setup or render Text mismatch warning, value flickers on load Default to null/placeholder, set the real value in onMounted Reading window, navigator, localStorage in the template's data path Throws on server, or mismatches if guarded naively ref(defaultValue) + onMounted to correct it Whole widget only makes sense client-side (canvas size, browser-only lib) Mismatch or server crash Wrap it in <ClientOnly> with a #fallback v-if="import.meta.client" branching a hydrated template Structural mismatch, guaranteed, every load Don't branch markup on the flag — use <ClientOnly>/onMounted instead Relative time / genuinely-expected drift you've accepted Warning you don't want to see data-allow-mismatch="text" (Vue 3.5+) — after you've confirmed it's harmless <div> inside <p>, broken table markup Mismatch with no obvious cause in your JS Fix the HTML nesting; lint markup Grammarly / extensions injecting attributes Attribute mismatch you can't reproduce locally without the extension data-allow-mismatch="attribute" on the affected element <script setup> import { ref, onMounted } from "vue" // Server-safe default — identical on both renders. const clientValue = ref(null) onMounted(() => { // Runs only after hydration succeeds — safe to diverge here. clientValue.value = computeSomethingClientOnly() }) </script> <template> <p>{{ clientValue ?? "Loading…" }}</p> <!-- For whole subtrees that can never run on the server: --> <ClientOnly> <BrowserOnlyWidget /> <template #fallback><span>Loading…</span></template> </ClientOnly> </template>
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Key takeaways
A hydration mismatch happens because Nuxt renders your app twice — once on the server, once in the browser — and hydration assumes, without verifying up front, that both renders agree.
The near-universal cause is a render-affecting value that isn't guaranteed identical on both sides: Math.random(), Date.now(), or any direct read of a browser-only API.
onMounted fixes values that are allowed to differ once hydration is already done; <ClientOnly> fixes whole subtrees that can never run on the server; data-allow-mismatch only silences a warning you've already confirmed is harmless.
Never branch a hydrated template's markup on import.meta.client/.server — that's the one "fix" that reliably causes the exact bug it's trying to solve.

🧠 Test yourself
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
One more render to get right
That tip-of-the-day widget from the top of this article has an honest fix now — a ref that starts null and fills in after mount, instead of a Math.random() call sitting directly in the render path. The bug was never really about randomness; it was about where the randomness ran, and Nuxt was always going to run it twice.
What's the strangest hydration mismatch you've had to track down — a third-party script, a timezone, something stranger? Drop it in the comments; there's a decent chance someone else's next [Vue warn] is exactly the one you already solved.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
GitHub — follow me and star the projects: github.com/parsajiravand
💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
📸 Instagram — frontend best practices, daily: @bestpractice___

Open 3 pts · Sep 20, 2026, 4:26 PM
dev.to 2 days ago Unread
Building a high-concurrency sports data platform is no easy task. When we started developing Cocatips - Live Football Scores & AI Predictions, our goal was to process data for over 80,000 football clubs globally without crashing the server or sacrificing UX.
In this post, I will share the architectural approach we took using Nuxt 3 and Vue.js to handle massive data structures, specifically for live sports events, standings, and algorithmic predictions.
1. The Challenge of Real-Time Sports Data
Football fans demand instant updates. Whether they are looking for live scores, fixtures & schedules or diving deep into head to head (H2H) & stats, the data delivery must be lightning-fast.
Additionally, our platform processes advanced algorithmic data for sports analysts. We needed a UI that could seamlessly switch between displaying a standard match tracker and outputting deep analytical models, such as:
1x2 prediction today probabilities.
BTTS / GG predictions (Both Teams To Score).
Over 2.5 goals predictions.

2. Dynamic Routing for League Standings
To handle SEO and dynamic rendering for thousands of leagues, we utilized Nuxt 3's Nitro engine. We set up ISR (Incremental Static Regeneration) for our highly-visited pages.
For example, when fans check the Live English Premier League standings, table & results, the page needs to show up-to-the-minute goal differences and points. By caching the initial HTML at the edge and hydrating the live data on the client side via our API (datav1.cocascore.com), we achieved a sub-second TTI (Time to Interactive).
3. Building the UI Widget (CodePen Demo)
To demonstrate how we structure our Vue components without revealing our entire proprietary backend, I created a Vanilla JS/CSS version of our Standings Widget.
This widget fetches real-time data and can toggle between multiple leagues dynamically. Check out the embed below:

4. The AI Prediction Search Algorithm
One of the most complex parts of the system was allowing users to search through 80,000+ teams and matches instantly. We built a custom scoring algorithm in TypeScript that handles fuzzy matching and ignores diacritics.
If a user searches for a specific matchup looking for an expert correct score prediction, our search function applies tiered sorting. It prioritizes top-tier leagues (like the Champions League or La Liga) over regional youth leagues, ensuring the most relevant matches appear first in the modal.
Conclusion
By combining Nuxt 3's server-side rendering with a robust Redis-backed Node.js API, we successfully created a platform that delivers both sure home win predictions and deep statistical insights without breaking a sweat.
If you are a Vue developer interested in sports data, feel free to check out the live architecture on our platform at Cocatips.com and explore our daily mathematical predictions.
Open 3 pts · Sep 20, 2026, 10:45 AM
dev.to 4 days ago Unread
My wife was interested in tarot cards, and I was a frontend developer looking for an excuse to build something. I put together a page with a deck, a card-flipping animation, and an AI-generated reading.
With AI assistance, the basic flow worked in about two hours.

I thought authentication and payments would get it ready to launch. Then the questions started: What happens when someone switches devices? What if a paid reading stops halfway through? Can I change a guided exercise without changing the meaning of someone’s saved answers?
Eventually, another question became harder than any of those: how do I get people to use it?
The project is now Mystic Journey. It includes a daily card ritual, guided explorations, a personal journal, and an optional companion board.
I’m still looking for early users. This is a development retrospective—and, openly, an invitation to try what I’ve built.
Why I changed the product
The original experience was straightforward: ask a question, draw cards, read the interpretation, leave.

But a general-purpose AI can already explain tarot cards. A nicer animation wasn’t enough to explain why someone should return to my website.
I started thinking about the experience around the reading. Writing in an empty journal can be surprisingly difficult. A card, a specific question, or a few choices might make it easier to begin reflecting.
That became the daily ritual: choose your mood, reveal your daily card, read a reflection prompt, and optionally write a few words. The aim is to make a small check-in easy, even when you don’t have a major question to ask.
For people who want to go further, I added six guided themes covering space, boundaries, change, confidence, connection, and direction. Each has five short chapters: notice the situation, consider another perspective, choose a small action, identify obstacles, and find support.
These explorations use curated branches rather than generating every step with AI. Earlier choices influence later options. Personal notes are optional and don’t automatically trigger a model call.
This gives me content I can review and improve consistently. Open-ended AI readings still have a place, but they don’t need to power every interaction.
Completed rituals, explorations, and explicitly saved readings go into a personal Journey timeline. The intention is to give people something worth revisiting: their own thoughts and choices, not just a list of cards.
I also added an opt-in weekly companion board. It shows names, avatars, and activity points, but not private reflections. Points are separate from purchases and don’t depend on someone’s mood or how much they write.
There’s a tension here: even a gentle leaderboard can create comparison. Whether it feels supportive is something I need users to tell me. These are product hypotheses, not proven retention improvements.
A small stack, with more state than I expected
The stack is Nuxt 3 for the statically generated frontend, a separate Fastify API, SQLite, and DeepSeek for generated readings. Nginx routes API requests to the backend.
For the current scale, I want something I can deploy and troubleshoot on my own. The difficult work has mostly been in the behavior of the application, not the number of services.
A daily card becomes account data once it connects to reflections, history, and rewards. Someone opening the site on their phone should see the same record they started on their laptop. Completion and rewards need consistent server-side state.
Disabling a button helps the interface, but it doesn’t prevent retries or requests from another tab.
Guided exploration saves have a similar problem. If a laptop has already saved the next chapter while a phone still has an older revision, accepting the phone’s write could overwrite newer progress.
Relevant save requests carry a revision. A stale write gets a conflict response, and the frontend keeps the draft while offering to reload saved progress. Identical committed retries return the existing result.
These are easy details to overlook when you only test one browser tab on a reliable connection.
The content needed versioning, too
One of the more interesting problems came from changing the exploration itself.
Suppose a saved answer says the user selected option 1. If I reorder the options next week, that stored value can appear to mean something different.
This is a simplified example of the problem:

Version 1, option 1: Take time to reflect privately Version 2, option 1: Talk to someone you trust
The number didn’t change. The user’s apparent answer did.
An exploration therefore retains the content version it started with. Old option meanings need to remain stable, and the live chapter and saved recap need to interpret answers through the same branch logic.
Once copy gives meaning to stored answers, editing it is also a data-compatibility decision.
Weekly reflections brought another state problem. A generation request can time out, a retry can start, and the original request can finish late. Without a guard, that older result could overwrite the newer one.
The implementation uses a generation state, an expiring lease, and a version condition when writing the result. A short database transaction protects state changes; it doesn’t stay open while waiting for the model.
A paid AI request has more than two outcomes
The demo treated generation as success or failure. Streaming makes that less tidy: a request can return some text, stall, lose its client connection, or finish without usable content.
Deep readings use server-sent events. The server applies a timeout and attempts to cancel the upstream request if the client disconnects early. Only a complete successful result goes into the result cache.
If generation fails after credits have been deducted, the error path restores those in-app credits and provides a local card interpretation. Restoring credits is separate from refunding a payment through the payment provider.
Weekly reflections also have a model-call budget and a local fallback. A free feature still needs a cost boundary.
This isn’t a complete recovery system. For example, a process crash after a deduction cannot be recovered by a catch block in that same process. Durable job state and reconciliation remain areas to improve.
That distinction matters: having an error handler is not the same as having verified recovery from every failure.
Security extends beyond the prompt
The model generates text. It doesn’t decide account permissions, payment status, or credit balances, and it has no database or payment tools.
That limits the consequences of a manipulated response, but it doesn’t solve prompt injection. User input can still interfere with the intended task, and model output needs to be treated as untrusted content when it reaches the UI.
The service currently has request rate limiting, body-size limits, and account and credit checks before paid generation. These are basic controls, not a claim of comprehensive protection. IP-based limits have limitations, and input handling and output rendering require their own review.
For accounts, passwords use salted hashes, the database stores hashes of session tokens, and session cookies use HttpOnly and SameSite settings, with Secure enabled for production. Verification codes expire and have attempt limits.
Payments have a separate trust boundary. The backend confirms payment status with the provider and verifies webhook signatures. Order-status requests check ownership. Since polling and a webhook can both confirm the same payment, crediting an order must be idempotent.
Privacy also affects ordinary feature decisions. The feedback form doesn’t automatically attach someone’s journal entries. Analytics can record that a reflection was saved without collecting its text.
I still have security work to do. Keeping track of what each component can access—and where personal content goes—has been more useful than treating safety as a few lines in a system prompt.
Performance and localization added their own work
Static generation lets me serve public pages separately from account APIs, but it doesn’t automatically make the experience fast. Images, animations, authentication checks, and API latency still matter on a phone.
The Journey timeline is paginated, details load on demand, and uploaded avatars are cropped and compressed to WebP in the browser. Streaming also needs the proxy to cooperate: response buffering can hide the incremental output the server is sending.
The interface supports eight languages, while the card data is primarily English and Chinese, with English fallback elsewhere. Those are different levels of localization, and I shouldn’t present them as equivalent.
A recent bug was a good reminder of the extra surface area: an email placeholder contained a literal @, which the i18n message compiler interpreted as special syntax. The JSON was valid, but the page failed. A JSON parse check alone couldn’t catch it.
Even a small copy change needs the right kind of validation.
Shipping didn’t bring users automatically
I’ve shared the product on Solo, 出海栈, 小众软件, and Indie Hackers. That has given it places to be discovered, but I don’t yet have enough evidence to call any channel a repeatable source of users.
I’m also learning to separate people who enjoy a development story from people who want the product. Developers may find concurrency handling interesting. A potential user wants to know what they can do, how much effort it takes, and whether their writing stays private.
There’s overlap, but a well-read technical post doesn’t prove demand.
My next experiments are technical retrospectives like this one, short product demonstrations, and content built around concrete reflection scenarios. Search is another ongoing task: a readable sitemap and a renderable page are necessary pieces, but I still need public content that answers something people actually look for.
What I want to measure is the path after a visit: does someone complete a first ritual, continue to another exploration chapter, or return a few days later? Knowing where that stops should help me choose between improving the entry flow, the content, or the acquisition channel.
Writing more code is the comfortable option. It gives me a visible result. Asking someone to try the product can end with “I’ll take a look” and nothing else. I’m trying not to mistake the comfort of development for evidence that another feature is needed.
Where the project is now
Mystic Journey is still early. I’m looking for a small group of people willing to try it and tell me what feels useful, confusing, or unnecessary.
The daily ritual and guided explorations are free; deeper AI readings use credits. If tarot is unfamiliar, you can start with an exploration theme that fits something you’re thinking about.
Try Mystic Journey
Specific feedback would help most: where you got stuck, what didn’t make sense, or where you stopped wanting to continue. There’s a feedback form in the app, and comments here are welcome too.
If you’ve taken a side project beyond the demo stage, I’d also like to hear how you found your first users who came back.
Open 3 pts · Sep 18, 2026, 9:59 AM
medium.com 5 days ago Unread

Practical patterns for making accessibility part of component development before issues reach QA or production.
Continue reading on Medium »
Open 3 pts · Sep 17, 2026, 7:25 PM
javascriptweekly.com 8 days ago Unread
#​802 — September 15, 2026
Read on the Web
JavaScript Weekly
Functional Programming Jargon, Mapped and Explained — Currying, purity, functors, monads…? If FP terms ever go over your head, TC39 delegate Hemanth HM's popular jargon reference is now an explorable map of concepts showing how they relate, each with a simple definition and JavaScript example.
Hemanth HM
Stay Sharp in the Age of AI — Master.dev instructors build at Anthropic, OpenAI, Netflix, Google, and Stripe. Learn from them with hundreds of courses, live workshops, and expanded AI learning paths. New members get $100 off a yearly membership.
Master.dev sponsor
Modern Web Types: TypeScript Support for Newer Web APIs — TypeScript's DOM types only include APIs shipped in multiple browser engines, so things like element-scoped startViewTransition and fetchLater throw up errors. modern-web-types is a drop-in lib.dom replacement that adds missing interfaces/members shipped in any single browser engine.
Philip Walton (Google)
React 19.3 Released — A significant minor release making view transitions and Fragment Refs stable, adding Trusted Types support, and more. We covered it in more depth in last week's React Status.
The React Team
IN BRIEF:
🔒 Attackers are scanning for exposed Vite dev servers exploiting a server.fs.deny bypass (patched in Vite 7.3.2 and 8.0.5 earlier this year) to grab credentials from .env files. Vite listens on localhost by default, but double check your setup.

Safari 27 shipped yesterday (alongside macOS/iOS 27), bringing the module loader rewrite we covered last week, so top-level await now works reliably in all major browsers.

TC39 meets in Tokyo in two weeks. Here's the agenda with three iterator helper follow-ups (join, includes and chunking) all up for Stage 4, plus more.

🔒 Starting this Thursday, the OpenJS Foundation's CVE team is taking a break till October 6 due to burnout driven by a surge of AI-generated reports. Actively exploited issues will still get a response.

🔒 npm now places a temporary 72-hour 'security hold' on any account after a successful recovery-code sign-in.

RELEASES:
pnpm 12.4 – The package manager can now manage Rust crates and Python packages alongside npm ones in a single workspace.

Playwright 1.63 – Tests can now declare a named lock so those sharing a resource never run concurrently.

Node.js v26.8.2 (Current) and v24.21.0 (LTS) – Both include a security release of Undici to fix numerous vulnerabilities.

React Router 8.4, Vite 8.3, Moment.js 2.31.0 (security release).

📖  Articles and Videos
A Design Space Exploration of async/await — A simple async/await example produces different results in JavaScript, Rust, Python and Swift. Test your mental model with a quiz, then learn about the design choices behind the differences.
Gavin Gray
Native is Now the Future of Mobile at Shopify — Shopify is rewriting its React Native mobile apps in Swift/Kotlin. The main argument is coding agents have cut the cost of writing everything twice, while the upsides of native remain.
Mustafa Ali (Shopify)
Workshop: From an Error to the Logs That Explain It — Reading logs next to traces and errors, holding context across services, and cutting the noise.
Sentry sponsor
A Deep Dive into StyleX — A hands-on tour of Meta's StyleX, which turns JavaScript style objects into plain atomic CSS at build time, and why it can be a good fit for coding agents in particular.
Flavio Copes
📄 'Nobody Pays for Open Source: We Can Force Them To' – Laurie Voss ran npm for five years and thinks registries are an untapped lever for funding maintainers. Laurie Voss
📄 Anecdotally, Programmers Dislike reducemap and filter sail through code review, but reduce draws complaints. Evan has a few theories why. Evan Hahn
🛠 Code & Tools
SnapDOM 3.0: Turn DOM Elements Into Images, Canvas and More — Zero-dependency html2canvas alternative with support for pseudo-elements and Shadow DOM. v3.0 adds 'incremental recapture' for faster repeat captures, automatic web font embedding, and fromString() for turning raw HTML markup into images.
Zumerlab
Your App Didn't Get Slower. Your Data Got Bigger — TimescaleDB extends Postgres so queries stay fast at scale, even as your data grows. Get $1000 credit to start.
Tiger Data (creators of TimescaleDB) sponsor
Fallow: Codebase Intelligence for TypeScript and JavaScript — A fast, zero-config Rust binary that finds dead code, duplication, circular dependencies and complexity hotspots in JS/TS projects. It's also pitched as a deterministic check for AI agent coding loops. GitHub repo.
Bart Waardenburg
🎨 category-colors: Generate 'Least Wrong' Color Palettes for Charts — Matt devised this algorithm for picking categorical chart colors in 2022 while design director at Stripe. It's now available as an npm package, and you can try it out on the Web here.
Matt Ström-Awn
React DevTools 8.0 (Chrome Web Store) – The Suspense tab is now on by default, the Timeline profiler is gone (use your browser's Performance panel instead), and inspecting a DOM node now shows its matching React component.

Zod 4.6 – Adds .validate(), a boolean check that skips building errors and is up to 35x faster than .safeParse().success on invalid input with compiled schemas.

Javet 6.0 – Embed Node.js v26's runtime in the JVM for full interop with Java.

parse-xml 5.0 – The fast, compliant XML parser goes ESM-only.

🕹️ n64js 1.0 – A Nintendo 64 emulator in pure JavaScript.

📰 Classifieds
💻 Free live coding workshop, Sept 16. Build signup protection with Fingerprint that catches fake signups, no matter how they cover their tracks.
Flaky tests slowing down dev? Meticulous gives engineers confidence to ship faster by autonomously testing every edge case of your web app.
Zuplo puts every API, AI, and MCP request behind one gateway. Route traffic, guard your MCP servers, and cap your AI costs. Try it free.
📢  Elsewhere in the ecosystem
🕹️ This year's js13kGames entries are in with a record 317 games squeezed into 13KB each. Frank Force's SP13KTRA racer and the Hornbound roguelike are good places to start, and you can dig around the public repos of every project too.

Chris Coyier rounds up new and emerging HTML features worth knowing about, like the <geolocation> and <install> elements, as well as HTML-in-Canvas and improvements to <select> customization.

The first Three.js Conference took place in Paris last week, and Codrops has a live-blog writeup of both days, including Mr.doob's talk. No recordings yet.

Tailwind Labs, the team behind Tailwind CSS, is joining Shopify. Tailwind CSS is to remain maintained and open source – phew! 😅

Open 4 pts · Sep 15, 2026, 12:00 AM

Scroll down to load more stories.