EthanG

For the Scaling JS Playbook

Author:
Last Updated:
Climber scaling a mountain

I want to start this by saying that this is only my current thinking. If you've found a way that works better for you, that's fantastic. This is a description of what I'll call one of my favorite plays in my playbook for starting a JS project and leaving room for it to scale without pain. I am very specifically anti-monolithic framework, but not specifically anti-monolith. If everything in a project can fit comfortably in a small set of features, that's good. Don't do anything more than you need to do. But the last thing you want to do is get locked into a framework unable to realistically refactor yourself out of it. I suppose I can add this to the list of reasons I Hate NestJS. But the point of this article is to describe my current strategy for starting with a monolith to get going and slowly split out into microservices when and if needed relatively pain free.

As you read through this you may think that some changes are more difficult to make than it seems like I'm making it out to be. That's not my intention. My intention is to summarize a path of scale. Any major change to an app requires work, nothing goes perfectly all of the time. But I do think this helps keep the pain minimal.

Starting Small

Let's say you're building a productivity app. Users sign up, they track their habits/todos and their weight/calories as well. A common thing for me is to deploy a postgres database to Render and plan on using Prisma as an ORM.

The problem is, however, I always have a temptation to reach straight for something like GraphQL.

I'm thinking of how nice it is to have a fully documented, type-safe, and introspectable API, and forgetting how much work it is. I understand the fallacy of tools like Hasura or TypeGraphQL. I know that they miss the point. By generating GQL schema from a database you're not just creating security problems for yourself, but you're losing all of the advantages of being able to create a flexible graph based API. If you're just using GraphQL as a client side ORM or to remove client side over fetching, you're not using GraphQL. Nor are you setting yourself up to scale for the problems that GraphQL so gracefully solves. But we'll get back to those later.

So the question is, what do I fall back to?

For me currently, it's just NextJS. Make full use of it's server side prop methods and API routes. This sounds like a mess to some people, but let's start off right. Let's rewind even more.

Start with NX.

Starting with an "extensible build system" is, in my opinion, the key to scale without pain. The point of a monorepo is not necessarily to put all of a companies work into a single repository. We're not going from 0 to Google here. We are still starting small. We're talking about an NX workspace for this productivity app we're starting right now.

In fact, with a workspace started, we can start the project with:

npx nx g @nrwl/next:app productivity-app

However, the first thing I'd actually work on is creating a library for our Prisma schema:

npx nx g @nrwl/node:lib productivity-app-prisma

There's not much to add to this. Simply a .prisma file to start planning out schema, and a client file to export a global instance of the generated client to avoid issues with too many connections.

With a Prisma client in place, you can now call it inside of server only contexts. Meaning API Routes, getServerSideProps, getStaticProps, etc.

Now you can just start building the app. To get page data I recommend using TanStack Query SSR. This will allow you to get the advantages of server-side rendering + hydration without prop drilling. This blog uses TanStack Query and getStaticProps. An initial load of a page will show the last statically built version, then update with any changes during hydration. I build often enough that this works ok for a small blog.

For our app features, user sign-ups, todo-tracking, basically any POST request, Next API routes work just fine. If all you're doing is posting to a database to retrieve it later, there's nothing wrong with going this route. If for GET requests, you want client side rendering, you can add API calls to an API route as well.

To help keep things organized I like to centralize fetches to the API in a callable object.

Example of centralizing API in NextJS

On the note of the actual logic of the calls here, it would be a good idea to put that logic in a separate NX library. So instead of the Next API route creating a new user, it just calls a function in another library.

This sets things up so that when you want to move your API to it's own server, all you have to do is create new routes and call the same functions. Then from there just add a new root URL to your centralized calls on your frontend. As long as inputs and outputs are the same, no further refactoring is required.

But to get this app going for now. This is all you need. We've achieved the goal of setting ourselves up to be able to build a productivity app. The question of when more is needed, or when we need to "scale up", isn't necessarily about number of users or number of features. This setup can go very, very far.

As a quick note on state, do not add another state management library until you can not find an answer for what you're trying to do. I can tell you now that this app should never need one. For your own sanity, and for the performance of this app, make sure you understand how to manage state the React way.

When using analytics or third party scripts, I would recommend enabling NextScript web workers which in the background uses PartyTown.

Going Bigger

This productivity app is getting popular. It's gained a lot of users, you've added paid plans, everything is going well. But now you've got some new ideas. Let's say that you want to add a feature to recommend new habits for people to add. In order to create these recommendations you're going to look at accounts with similar habits and find what they are also doing. Or even better yet, you want to help people find balance. For example, someone reads and codes everyday, but has no exercise habits, so you might put a "Go for a walk" recommendation in front of them.

As an additional feature, if someone is missing a particular habit often, the app might recommend that they plan to do it less often. Such as every 3 days instead of every day. This helps them stay consistent without learning to ignore it.

You determine to build a series of algorithms that run for every user and build a unique recommendation list for them that will show up on their page. This is certainly not something you want running on a server route every time they try to go to their profile. It's going to cause some major performance problems.

Someone might suggest serverless functions to run CRON jobs, but this isn't quite the right fit. You've got more features planned and you want to keep control over how all of this is handled. Maybe this is better triggered every time someone adds or removes a habit to help keep that recommendation relevant to the data its pulling from.

Now might be a good time to move to a standalone Node server. But there's no reason to restrict the app to the rules of REST. And, we probably want end to end type safety. Until now you may have been inferring types from Prisma and passing that into NextJS props.

At this point I would recommend using tRPC to build this new server. Add it as a new app on your monorepo, and set it up to call the logic you put in your API library earlier. tRPC is a natural fit for monorepos with the way it shares types.

Then go into the centralized object and update the calls to use the tRPC client. As long as the inputs and outputs at the same, no further refactoring is required.

The reason I didn't recommend starting with tRPC is because in terms of starting the app as a small serverless build, I think using it as a NextJS API route, similar to GraphQL, kind of misses the point. There are plenty of tools that require less configuration that can do the same job. NextJS handles itself at that size just fine. So tRPC is really needed if you never make it to this point.

But with tRPC setup as its own server, you can start triggering any heavy processes as jobs every time someone adds or updates a habit to their profile. With TanStack queries cache invalidation, it will start pulling new recommendations after they're written to the database by these jobs rather than waiting on them to show the page.

You've now got a space for dedicated backend work and you can start getting fancy without overloading a serverless frontend deployment or slowing down your users experience.

All with minimal refactoring. By just calling the same logic from a new server we didn't have to copy/paste a bunch of stuff into a new repository or go through a painful process of rewriting everything under a new context.

Going Biggest

This app exploded in popularity. You've got 2 separate teams to maintain the frontend and backend. And you've got a huge list of features in the works.

You want people to be able to follow one another. Maybe add some social network features like posting updates, images, adding likes etc. Images will be hosted with Cloudinary to help with performance and handle transformations/filters. You want to synchronize calorie and activity tracking with smartphones. You'd like a way for people to track their finances and create a budget. This will integrate with the Plaid API. You want to start splitting off all these new features into microservices to keep things manageable and testable in isolation.

This means using an API gateway to route calls to all of these services and build flexible responses. And as Netflix learned, REST is not the right answer for that. It needs to be well documented, type safe, and expandable to handle any number of services.

This, finally, is where GraphQL comes in. Using GraphQL as a gateway we can use federation to merge multiple graphs into one central API. All services, even third party APIs can be put on a graph with relationships created between them to allow for nested calls where appropriate.

The only way to do this right, for it to match the needs of this app or any app, is to write the schema incrementally by hand. Not generate it. We need to establish for example, that on the graph a User node has a relationship to a Plaid node. Or a SocialPost node has a relationship to a CloudinaryImage node. You need the relationship structure of a relational database without everything being on the same database. Or to put it another way, you need to maintain control over the relationships. This is the point of GraphQL. It is the point of graphs. The relationships are the most important part.

As this becomes established, the frontend can use a mix of TanStack Query and and Prisma's graphql-request library. You could move to Apollo Client, but to be honest, doing SSR correctly with Apollo Client and getting caching right it's actually kind of difficult. To me it's easier to just combine graphql-request and TanStack. And because we're already using TanStack, once again this is just a case of updating our centralized API object.

As long as the inputs and outputs are the same, no further refactoring is required.

Concluding

Just to reiterate, this is only my current favorite play in my playbook. It's not the answer to every app. But it's a pretty decent one in my opinion. It's allowed me to move forward without worrying too much about the future. In 2-3 years time I'll likely have an entirely different opinion. That's how technology works.

By moving logic, validations, etc., into libraries, the way that code is delivered becomes increasingly unimportant. It separates implementation details from delivery details. I think that's a great way to keep things flexible and open to evolution.

Continued reading: Organizing NX, Prisma, NextJS and GraphQL

As some additional resources, I recommend the following two videos. One is a short conversation on microservices, the other is the previously linked video on how Netflix scaled it's API: