May 14, 2025
On a rainy day I decided to start a conversation about websockets and sharing state between the frontend and backend of a dynamic web app. The core of my idea revolved around having one BehaviorSubject (a 'hot' Rx Observable) that appears in one place and manages state for all of its subscribers at both the database level and in the frontend client-side code.
This means that we can have a dynamic subscription that will pull in our data and allow us to compose dynamic streams at-will wherever we want in our isomorphic code base. The resulting repo is here on Github. It contains quite a bit of code with some neat features. This won't be a comprehensive tutorial, but I'll attempt to explain the important snippets in this article. Simply rename the .env.sample
file to .env
and follow the Readme if you'd like to setup the project and tinker with it.
So, I kept asking about this and GPT kept saying it's effectively impossible. The code requires some level of duplication between the client and server, which is a fundamental limitation. Considering SolidStart bridges these gaps for us, I argued:
This has to be possible in some way, shape, or form! Can we take a stab at it?
After a lot of back and forth and some trial and error, I came up with the idea of using RPC over a socket in SolidStart, using tRPC to generate our bindings. RPC is like REST but with type-safety across the board. Now the lines are blurred, and its effectively what we were looking for.
Instead of communicating via GET, PUT, POST, DELETE, as a typical full-stack backend-to-frontend setup would do, we use function calls that directly invoke backend code. The relationship allowed me to establish a solid line of communication from our frontend to our shared BehaviorSubject, effectively bridging this gap!
Setting up sockets in Solid turned out to be pretty difficult, since it's technically too new and an experimental feature. The key was to modify my config to setup an http route that forwards itself into a socket on the backend.
//app.config.ts
export default defineConfig({
server: {
preset: "node_server",
experimental: {
websocket: true,
},
},
vite: {
plugins: [tailwindcss()],
}
}).addRouter({
name: "trpc",
type: "http",
handler: "./src/lib/trpc.ts",
target: "server",
base: "/trpc",
});
Then I setup the tRPC lib with an event handler to support socket integration with applyWSSHandler
.
//src/lib/trpc.ts
export default eventHandler({
handler() {},
websocket: {
async open(peer) {
if (!wss) {
wss = new WebSocketServer({ noServer: true });
applyWSSHandler({
wss,
router: appRouter,
createContext,
});
}
wss.emit("connection", peer.websocket, peer.request, peer);
},
},
});
Now, with this powerful setup my first idea was to have the backend own a timer that would kick-start a counter that pushes data to the frontend. The frontend binds to a subscription that receives the pushes and updates the client. This can't be accomplished with ReST, which is where our socket binding comes into play. Luckily, tRPC supports sockets with their client code that looks like this:
const client = createWSClient({
url: socketUrl,
connectionParams: { token },
});
return createTRPCClient<AppRouter>({
links: [wsLink({ client })],
});
This websocket bridge with RPC behind it is very powerful, and allows us to cross the line of client/server seamlessly, giving us the ultimate setup for a chat bot or a video game where pushes to the client are necessary. Now, as the timer ticks, in any open window we see an update.
The frontend can also communicate to the backend and force increment and decrement the timer for all consumers. Note: I also mixed in auth on this particular example, so you need to sign up and login to start the timer. Above you'll see a screenshot of what it looks like in action.
Using the Drizzle ORM I am also able to sync a database on the backend. There is a slight delay from the socket, but thanks to the single-threaded nature of Node.js and async/await, we don't have to worry about concurrency issues here (one update will always happen before another).
If you remember my article on DBest There is a simple Todo app that dbest comes with. I decided to redo it with sockets, so everything works seamlessly. One person can update the database in one tab, and then changes appear instantly in another.
Now if you source the .env
file and run bunx drizzle-kit studio
you'll see the database is completely syncronized.
The other thing to note, is thanks to the dynamic nature of Solid.js and the simplicity of observables, the api's cross over nicely. For instance, in my shared folder you'll see the timer code is very simple.
export const counter$ = new BehaviorSubject<number>(0);
export const isRunning$ = new BehaviorSubject(false);
export const increment = () => {
const newCount = counter$.value + 1
counter$.next(newCount);
return newCount
}
export const decrement = () => {
const newCount = counter$.value - 1
counter$.next(newCount);
return newCount
}
Consuming these functions is simple, and we can just cast to .asObservable()
to manage our subscription.
//server/api.ts
increment: t.procedure.mutation(increment),
decrement: t.procedure.mutation(decrement),
onCounterChange: t.procedure.subscription(() => counter$.asObservable()),
Consuming this in the UI after setting up the client:
const client = getClient()
const incrementRPC = () => client!.increment.mutate().catch(logError);
const decrementRPC = () => client!.decrement.mutate().catch(logError);
export default function Counter() {
const [count, setCount] = createSignal<number | null>(null);
onMount(() => {
const valueSub = client!.onCounterChange.subscribe(undefined, {
onData: setCount, onError: logError,
});
onCleanup(() => {
valueSub.unsubscribe()
});
});
return (
<main>
<h1>
<Suspense fallback={<span>Counter:</span>}>
Counter: {count() ?? '…'}
</Suspense>
</h1>
<div>
<FancyButton onClick={incrementRPC}>+</FancyButton>
<FancyButton onClick={decrementRPC}>-</FancyButton>
</div>
</main>
);
}
Very clean, and no clunky websocket code or overly nested subscriptions/api calls.
By now you should have an idea of how to share a state via backend and frontend with push notifications and a sweet tRPC setup. I'd recommend trying out the project if you're interested, it's easy to setup the database with Podman and get it going. As mentioned, the project could be a starter for anything really, including a chatting app, a game, or anything that needs live data streaming.
I've tried to make the code as clean and simple as possible, but I did add some fancy flowbite components and neat tricks for auth in there too (more on that in my other articles). So, have fun tinkering with the codebase and please feel free to send any feedback to my email at bdubois2@gmail.com