Building modern web applications with Service Workers can be complex. Workerify’s goal isn’t to simplify every aspect of Service Worker development, but rather to make it straightforward to create API-like endpoints directly in the browser. By providing a Fastify-like router that runs entirely inside a Service Worker, Workerify makes this specific use case both easy and powerful. In this post, we’ll explore Workerify’s architecture and how its Vite plugin and core library work hand in hand.
Architecture Overview
Workerify is made of two main packages:
- @workerify/lib — the core routing library, handling request matching and processing
- @workerify/vite-plugin — a Vite plugin that manages Service Worker registration and build-time optimizations
These two communicate through the BroadcastChannel API, letting your app define routes while the Service Worker intercepts and handles HTTP traffic.
The Journey of a Request
Let’s follow how a request flows through the system.
1. Initial Setup: The Vite Plugin
When you add the Workerify plugin to your Vite config:
// vite.config.ts
import workerify from '@workerify/vite-plugin';
export default defineConfig({
plugins: [workerify()]
});
The plugin takes care of several things:
-
Generates a Service Worker file at
/workerify-sw.js
-
Provides a virtual module (
virtual:workerify-register
) for easy registration - Handles both dev and build modes — served from memory in dev, emitted as a build asset in prod
2. Application Initialization
In your app code, you register the Service Worker and define routes:
import { registerWorkerifySW } from 'virtual:workerify-register';
import Workerify from '@workerify/lib';
await registerWorkerifySW();
const app = new Workerify({ logger: true });
app.get('/todos', async () => JSON.stringify(todos));
app.post('/todos', async (request, reply) => {
todos.push(request.body?.todo);
reply.headers = { 'HX-Trigger': 'todos:refresh' };
});
await app.listen();
3. The Registration Dance
When app.listen()
runs:
- A unique consumer ID is created
- The instance registers with the Service Worker at
/__workerify/register
- Routes are broadcast via BroadcastChannel
- The Service Worker acknowledges the registration
4. The Service Worker: Traffic Controller
The Service Worker, generated by the plugin, maps clients to consumers and routes. When a fetch
event occurs, it looks for a matching route and, if found, hands off processing to the right consumer through the channel.
5. Request Pipeline
- Interception — Service Worker catches the request
- Route Matching — Finds the right handler
- Broadcast — Sends request details to the consumer
- Processing — Consumer runs the handler
- Response — Returned via BroadcastChannel
- Delivery — Service Worker replies to the browser
Multi-Tab Support
Service Workers are shared across tabs. Workerify ensures isolation by giving each tab its own consumer ID and routes. Closing a tab automatically cleans up its routes, avoiding conflicts between tabs.
The Communication Protocol
Workerify relies on a simple, well-defined protocol over BroadcastChannel:
-
Route registration:
{ type: 'workerify:routes:update', consumerId, routes }
-
Request handling:
{ type: 'workerify:handle', id, consumerId, request }
-
Response delivery:
{ type: 'workerify:response', id, status, headers, body }
-
Debugging:
{ type: 'workerify:routes:list' }
,{ type: 'workerify:clients:list' }
Build-Time Optimizations
The Vite plugin improves both dev and production workflows:
- Pre-compiled templates for the Service Worker
- Virtual module generation for registration code
- Automatic base path handling
- Memory serving in dev for faster updates
Why This Architecture?
Unlike traditional Service Worker setups, Workerify removes boilerplate and gives developers:
- A familiar API (Fastify-like)
- Zero network latency — requests handled in-browser
- SPA-friendly design, perfect with HTMX
- Smooth dev experience — hot reload and route updates without reinstalling the SW
- Full TypeScript support
Performance Considerations
- Minimal overhead with BroadcastChannel
- Lazy route matching only when needed
- Automatic cleanup of closed tabs
- Memory efficiency by storing routes once in the Service Worker
Practical Example: A Todo App
import { registerWorkerifySW } from 'virtual:workerify-register';
import Workerify from '@workerify/lib';
import htmx from 'htmx.org';
await registerWorkerifySW();
const app = new Workerify({ logger: true });
const todos: string[] = [];
app.get('/todos', async () =>
`<ul>${todos.map(t => `<li>${t}</li>`).join('')}</ul>`
);
app.post('/todos', async (request, reply) => {
todos.push(request.body?.todo);
reply.headers = { 'HX-Trigger': 'todos:refresh' };
return { success: true };
});
await app.listen();
htmx.trigger('#app', 'workerify-ready');
Start now
Getting started with Workerify is straightforward. You can scaffold a new project in seconds with:
npx @workerify/create-htmx-app
This will generate a ready-to-use setup with Vite, HTMX, and Workerify so you can start experimenting right away.
Conclusion
Workerify makes Service Worker routing simple and powerful by:
- Separating route definition (lib) from SW management (plugin)
- Using BroadcastChannel for fast communication
- Handling multi-tab isolation automatically
- Offering a familiar, developer-friendly API
This design opens the door to offline-first apps, zero-latency SPAs, and new client-side architectures. Workerify is still evolving, but it’s a useful starting point for experimenting with Service Workers in new ways — and it can even serve as a stepping stone toward simpler application models, especially those built with HTMX.
👉 Try it out, experiment with your own projects, and feel free to share your feedback and experiences with me — I’d love to hear how you use Workerify!
A fresh Discord is available if you’d like to join and share with me 😁
Top comments (0)