Fullstack types with Postgres, Hasura GraphQL and Typescript

Thomas Maximini

Thomas Maximini / January 22, 2023

12 min read

Background

I started working at Crowdcast in March 2020, just a week before the first Covid lockdown started in Germany. Crowdcast is an online platform for running live video events and building a community around it. The lockdowns led to a sudden spike in demand for live video events, so Crowdcast saw loads of new signups and a significant increase in traffic.

The stack Crowdcast was ran on was a client side application written in Angular 1.x. and a monolithic API written in Node.js (express), both hosted on Heroku.

During that time there were two main data stores: Firebase realtime database and a MongoDB cluster. Both of those data stores are document-based and they were completely encapsulated from each other, so there was no relational integrity and they had to be kept in sync using background jobs.

Especially the Firebase realtime database proved itself as a bottleneck: Too often we found ourselves in a situation where it showed 100% load and became unresponsive, meaning that any requests relying on it could not be served anymore. And it could not be scaled up any further, their “pay-as-you-go” plan was the only option they offered.

We’ve already spend a serious amount of time trying to mitigate the issue by using caching but the core problem remained: Firebase would eventually reach 100% capacity and block any new requests for a couple of minutes.

From a business perspective this was a nightmare for us. Running an online live event platform we needed to ensure full availability around the clock. If we had one high-scale event (more than 10k live users was usually considered our danger zone), this could bring down the entire platform for a couple of minutes and thus affect all live events that were running during that time period.

So we decided to rewrite the entire application from scratch using a more modern technology stack that would meet our requirements and serve us well in the future.

I was given the opportunity to come up with the architecture for that new platform. Keeping in mind the scaling problems we were experiencing previously, it was essential that this new stack would allow us not only to exceed the current traffic requirements but also to grow far into the future with us. Our bold goals for Crowdcast were live events with more than 100k users, which would be a 10x from our current user limit.

The requirements

When we decided to write Crowdcast from scratch, we were facing the question of what technology stack to use. As the lead architect during this time, it was my job to come up with a proposal that would best fit our requirements.

Those requirements were:

  • Compatibility with v1 data sources, since we needed interoperability between the two versions, even though they run on completely different technologies.
  • Scalability of the API and database beyond the limits we were experiencing within the original Crowdcast platform, aiming for 100k+ users per event.
  • Developer velocity: Since we are a small engineering team we wanted everyone to buy into the new stack and to be comfortable and effective quickly.
  • Performance improvements: We aimed to deliver a better and faster user experience for our customers than in the previous version.
  • Flexibility and future proof: Since we never know which challenges we might face in the future, we wanted to keep a high level of flexibility in case we need to adapt to technological advances. We also need to assure that our technology choices are likely to still be relevant and supported ten years from now.

Choosing the stack

Typescript

I remember when Typescript started to take off around 2018 I felt a little intimidated by it, even though I was writing Javascript already for more than 10 years. There was just something about the look of those complex types and compiler errors that threw me off.

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface Part {
  id: number;
  name: string;
  subparts: Part[];
  updatePart(newName: string): void;
}

However I did a few projects in it and can now confidently say that I would never want to go back to writing regular Javascript. While this might still work for solo projects, I’d argue that as soon as there is a team involved the benefits that Typescript brings to the table are massive.

Besides annotating the code and thus making it much more maintainable it reduces the amount of bugs that make it into the codebase (or production!) drastically. In the long run this becomes a huge time saver and it also increases the confidence in the code when shipping, since most runtime errors can be avoided by using Typescript.

It’s also worth noting that most of the types do not look as intimidating as the example above, but are actually easy to read and understand. A lot of the more complex types are actually automatically generated through our tooling. More on that later 🙂

Frontend

For the client we quickly agreed we wanted to use React. It is a very widely used framework, we all had experience with it and we were confident that it is a safe long-term bet. We also really liked the React eco-system with all the cool open-source tools and libraries for it.

I am a huge advocate of using tools that make our life as developers easier. Next.js is a framework built on top of React that provides server-side rendering capabilities, routing and various other optimization techniques allowing developers to create performant web applications with ease.

Both React and Next.s also provide a great developer experience, with hot-reloading and built-in debugging tools. This makes it much easier for developers to quickly iterate on their code and keep up with changing requirements.

We are using Vercel for deployments, the company behind Next.js. Their pricing is very reasonable and they offer automatic preview url for Pull Requests, which is a feature we use extensively in our quality assurance and code review process.

State Management

We realized that not using a state management library from the beginning was a mistake. We had implemented context and local state, but it wasn't an effective way to manage the state. We are now in the process of migrating our state management to jotai, a library that is similar to Recoil.js and based on atoms. This will help us make our state management more efficient and maintainable.

Datastore and backend

The elephant in the room was what datastore we should choose. It was clear that we wanted to go cloud native and not run our own server infrastructure on premise. Our current Heroku setup was expensive and did not scale automatically, so we wanted to move our entire workload over to AWS.

After a few days of researching different cloud databases we decided to go with Postgres on AWS RDS. The reasons for this were that MongoDB and Firebase had not really served us well in the past: We ran into scaling issues with Firebase and RDS promised insane scaling options. And I saw a huge benefit in a clearly designed, relational data model over the sometimes chaotic data structures in NoSQL databases. Since we planned on running GraphQL as query language Postgres also came in handy as there are already a couple of tools that connect to your database and generate a GraphQL API on top of it.

In our case we went with Hasura as the GraphQL layer that sits on top of our Postgres database. That way, we don’t have to write any GraphQL resolvers ourselves, but instead get it out of the box based on our data model. This was extremely useful for us because our team was small and a bit inexperienced with GraphQL at first. But the team members adapted quickly to the new technology.

Hasura GraphQL

By choosing a Hasura and adopting its idea of a “self-serving data store”, our developer velocity increased noticeably. We just had to think about the data model and define it in Postgres and Hasura takes care of generating the GraphQL API on top of it.

There is automatic generation of API docs, so our frontend and iOS developers can quickly find what they need. Most features no longer require a backend developer but could be done entirely on the frontend by creating queries and mutations and embedding them directly into the application code.

Another cool feature of Hasura is that it allowed us to integrate all our legacy datasources as remote schemas. This helped us to provide a bit of interoperability between the two versions of the platform.

Using a 3rd party service like Hasura GraphQL comes with both pros and cons. On the one hand, the service offers a great deal of convenience, allowing users to quickly build an efficient GraphQL API. On the other hand, there are some downsides to using a 3rd party service, including the lack of control over the underlying code and relying on a 3rd party for critical production infrastructure.

Hasura makes it easy to integrate with other services and technologies. We could for example use our existing Firebase authentication system in combination with Hasura’s JWT auth approach. That way we could achieve some level of compatibility between our v1 and v2 platforms, that run on otherwise completely different tech stacks.

We also liked its approach of using serverless functions for custom actions, since we are excited about serverless technologies and the scaling and stability they promise.

Additionally, Hasura's community is very active, allowing users to get quick help and support from other users.

They also do monthly community calls where they share the latest updates and invite folks from the community to share things they built with Hasura.

In our experience at Crowdcast they were also super responsive to any requests and ideas and even proposed the idea of a shared Slack Channel that our team can use to communicate directly with the Hasura team. Their support has been really stellar so far.

Hasura cloud

While we initially went with the open-source version of Hasura, we later had to switch over to their cloud service. We started to run into more and more issues running Hasura on AWS Fargate. During this time Hasura cloud just came out offering advanced monitoring, so we migrated to that and it helped us to identify bottlenecks and various improvements in our GraphQL queries.

Another reason for us to switch to Hasura cloud was the response caching. Every production API will need some form of caching at some point and the GraphQL caching Hasura offers is very simple and effective.

Hasura cloud also makes it easy to deploy based on Git - just push your code to a certain branch and Hasura will pick it up and run the deployment. Since all our services are deployed using Continuous Deployment using Github actions, it is great to see that Hasura also offers this as well.

graphql-codegen

The last piece of the puzzle is this little tool that automatically converts our GraphQL types into Typescript types. Whenever we run yarn dev we generate a Typescript schema based on our GraphQL types. Yay 🎉

There is a huge ecosystem around graphql-codegen. For example we use its react-apollo plugin to parse our client code and generate React hooks for all our queries, mutations and subscriptions. So any GraphQL strings that are inlined in our application are parsed and hooks are generated for those strings.

The way we do it is we have one folder for each queries, mutations and subscriptions that contains the .gql files. Our yarn dev script runs the graphql-codegen and generates the hooks that can then be imported and used in the different components throughout the app.

This drastically reduces the amount of code that developers need to write, allowing them to focus on writing the actual application logic. Additionally, by using the generated code, teams can ensure that their application is well-structured, organized, and properly typed.

In addition to that, it allows for features such as auto-completion in your editor, if your editor can understand Typescript (VSCode is good at that).

TO summarize, graphql-codegen is an incredibly useful tool that can be beneficial for the development of any GraphQL based client application.

Summary

Now we basically have what I like to refer to as fullstack types:

  • We start at the database level where we define our Postgres data model. (Note: This is done through Hasura’s console and graphical interface, so the Postgres migrations are in source control. We want to avoid any manual changes to the data model, because we need to be able to re-produce those builds on different environments)
  • Hasura GraphQL picks up the Postgres data model and generates GraphQL schemas based on it. It’s worth noting here that you can easily swap out Hasura with any other GraphQL server, such as Prisma or even roll your own service. The important part is that a GraphQL schema is created that maps to the underlying database schema. This graphql schema then gets consumed by graphql-codegen.
  • graphql-codegen reads and parses those schemas and compiles them, together with and client side GraphQL operations, into Typescript definitions.

And all of this is happening fully automated, meaning your editor of choice already knows the return types of an executed GraphQL query.

This setup helped our team to iterate and build new features quickly.

I am still impressed by this setup after more than 2 years working with it!