Why We Rebuilt Our Stack for the Workflows Engine

Processing leads in seconds, and guaranteeing deliverables down the line.

Aidan Hibbard · Engineering
10 min read

The engine

The impetus for this migration was our new workflows engine. Lead processing has to stay under seconds, and it has to stay durable. Niche replies fast, then keeps going: qualify the job, follow up by SMS or voice, sync to your CRM, book the appointment.

That work is the product. If the workflow engine slows down, customers feel it as a missed job or a lead that someone else answers first.

Where we hit a wall

We started running that pipeline on Inngest. Every step.run() checkpointed over HTTP before the next step could run. Save state, callback, retry. We ran on persisted instances, and the per-step round trips were uneccessary to avoid the HTTP lifetimes that serverless apps have. What should finish in seconds was ballooning into minutes.

Latency is a killer for us. Our whole goal is to respond to leads faster than a business could. We couldn’t put our most critical lead functions behind that queue and still hit the bar we sell on.

Most frameworks have an answer here. Rails has Sidekiq. Laravel’s queues work the same way: a durable processor backed by Redis, running in-process without an HTTP hop between every step.

We’re on TypeScript, so BullMQ was the obvious move off Inngest. The open question was everything around it: how to ship API and workers from one repo without fighting the bundler.

Our lead pipeline needed workers that stay hot and share the same business logic as the dashboard.

What we needed

By then we had a shopping list.

  • Lead-speed workers on Redis, without an HTTP round trip on every step
  • Durability when a process restarts or a job retries
  • Plugin support in the bundler so we could hook the build
  • Two entry points from one codebase: an API entry and a worker entry, sharing models, types, and business logic
  • A server layer we could extend at build and runtime
  • No SSR. Niche is a dashboard, mostly client-rendered

That list pointed at a specific deploy shape.

We needed separate processes for the HTTP server and the job processor in production. One repo. One build in CI. Two entry points in the output: one starts the API, the other starts the worker loop. Both import the same models, types, and handlers.

The API enqueues work to Redis. Workers pull jobs and run the handlers. BullMQ does not care which machine runs which role. It only needs Redis in the middle.

We tried running API and worker in one Node process early on, since that’s how Inngest ran things before. Under load they shared one set of resources. A crash or deploy restart took down request handling and job processing together. Scaling workers meant scaling the API with them. Separate processes is the necessary change for that.

What about monorepos?

We reviewed a monorepo layout early on. The monorepo was not the hard part, it really came down to our database.

We use Prisma. So to share our schema as generated client code across the API and workers, we needed three packages in one repo: the app, the workers, and a database module that owned the schema and prisma generate output.

That sounds kinda nice till it’s time to deploy. One service can run migrations and restart before the other has picked up the new generated client. The app and workers can race each other on schema changes.

We did not want a workflow where you deploy, run migrations by hand, then redeploy so the other service can introspect the new changes. During the gap between migration and restart, leads fail. Business logic is already hitting columns and relations the running process does not know about yet.

Our options

Cloudflare put out Vinext for a Next.js-shaped dev experience on Vite (albeit entirely vibe-coded). We weren’t going to move our whole infra onto that.

Vike is powerful, but the ergonomics created friction.

Vite though was what we knew we needed, even if a “framework” couldnt help. Vite plugins let us hook the build and emit a second entry from the same tree. That is how we got the one-build, two-entry-point layout.

The UnJS team maintains Nitro as part of their next-gen JavaScript toolkit. Nitro ships as a Vite plugin you can drop into any app. Minimal, hookable at build and runtime, bring your own frontend. Nitro v3 is still beta, but Nuxt 5 is already lined up to launch on it. Nuxt has run Nitro in production since Nuxt 3, so we weren’t betting on a server engine that only exists in a blog post. The core felt less vibe-coded than Vinext.

We didn’t need SSR. Nitro is a drop-in backend. Dropping React Server Components and the client-vs-server page split meant less framework tax for the team. Astro could’ve worked too. We picked Nitro.

So we knew this was the next step: Vite for bundling, Nitro for the server, BullMQ workers on Redis, separate instances in prod, all from one repo.

How we migrated

Let’s be real, a port this size is a lot of files. Strict TypeScript and ESLint rules helped, and agents were useful for walking dependency graphs.

We dropped RSC, moved from Next.js ergonomics to Vite configs, and swapped Next-style server routes for Elysia.

We migrated by domain. Each developer owned a section top to bottom, starting with the most client-facing services. Start from a page or API endpoint, walk the graph, bring in every import. With tight lint rules the agents couldn’t skip a dependency.

We weren’t deep on Next-only APIs, so dropping things like next/image went fast.

Within a month we had a new repo spun up, ported, cleaned up, tested, and ready to cut over.

We moved to production with zero downtime, purely swapping the repos behind the “app” on our host. Lead handling didn’t go dark during the migration.

The outcome

Vites HMR, even with both NextJS and Vite on Rust was faster by a bit, and our build times dropped as well. This saves our team some hours every week, which adds up over time.

The biggest outcome though is that we can build our own tooling, often even faster than reaching for a SAAS solution we’d have to implement.

This allows us to observe what’s going on from our own tools, so when something happens we don’t have to correlate logs with Inngests dashboard. The surface for maintnence dropped considerably.

AH

Aidan Hibbard

Engineering