High-Performance CRUD Apps
Jacob Parris explores the intricacies of building and maintaining high-performance web applications at scale. He delves into the technical considerations that come into play when choosing the right architecture for an application, emphasizing the importance of stability before speed.
Jacob discusses the merits of server-side rendering (SSR) for providing user-customized content, highlighting it as an effective method to prevent layout shift from asynchronous asset loading. He underscores that optimizing the user experience is key when deciding the architecture of an application.
The talk also covers advanced data management strategies, such as local-first synchronization, as a way to manage local and remote data. Jacob introduces RepliCache as a promising tool to manage local data synchronization.
The presentation delves into the world of CRUD operations and User Experience, with a specific focus on the use of client cache loader. Jacob explains how layering improvements to the app—starting with reliable server loaders—can enhance performance.
Furthermore, he discusses the Remix progressive web app (PWA) package, endorsing the strategy of building a strong foundation using server loaders and then progressively enhancing apps' features.
Share this talk
Transcript
Alright, hi everyone, I'm Jacob Parris.
That's the first slide down already. This is my Twitter, in case you want to steal a QR code. So when Kent asked me if I would speak to all of you, I was excited, because I had been to conferences before. I actually met Kent at one, two or three or four years ago, Zadar Croatia.
It was a huge auditorium with this big center stage, and he came on, he delivered his keynote in 360 degrees to this packed room, just full of people all the way around, it was super super cool. And then afterwards, they ran curtains across all the way down the room, and they subdivided
the whole thing into quarters, so that they could run four talks at the same time. If you're a big name, like a Kent-type character, then your quarter is going to be packed. But if you're a lesser name, like say, my height on the totem pole, running a talk at
the same time, you get a quiet little corner in the room, maybe ten people in the audience, half of them are on their phones, and that's kind of what I thought I was getting into when I decided to come to this conference. But then the schedule came out, and not only is this conference single track, so everyone
coming gets to see every talk, but they also put me at the end. After all of these people, you can even imagine, I never worked for Feng, I've always worked at normal companies, so small teams of five developers building software that could have
been a spreadsheet, with servers designed to handle multiple requests per minute, users working nine to five, clicking around at the speed of a mouse, until someone double clicks and breaks the whole site.
So when you're building apps for millions of users across the world, there's lots of room for things to go wrong, but most developers aren't working at that scale. If your product only has to support thousands of users, or maybe even less, and mostly during business hours, what excuse do you have to build a slow, buggy app that breaks at the slightest touch?
So I'm here to talk about high-performance CRUD applications, using Remix, like a lot of us are. This is basically RemixConf here. So CRUD means create, read, update, delete, and we're talking about basic operations that are used in all sorts of apps.
In my case, in my career, I've worked on a lot of internal company software, like asset management systems, CRM tools, administration panels, and fundamentally, they're all quite similar. They usually have one or two main documents in the database.
A big part of the product is about presenting and modifying those documents. They're not that complicated, but the bar for quality is just through the floor. I don't know why. Maybe it was the JAMstack era of client-side state management that told people, trying
to sync with external backend servers, which is sometimes hard to get right, but things are better now, we have better tools, and people's expectations are just really, really low. I used to have a client, like a real developer, and I was working on this six-week project.
I'm just going to put the water down, I'm not even drinking it. So we're finishing this five-day project we budgeted, which took six weeks. As we were handing it to the QA, the engineering manager was like, hey, this is a really big deal for us.
We can't afford anything to go wrong, so please really try and iron out all the bugs. Really try and break the app, like by, I don't know, clicking the back button and refreshing the page. And I'm like, that's the bar? That's the standard I'm being held to?
If I had known that, I could have done it in four days. So it shouldn't be possible for your product to break just because one user does something weird. But also, whenever I hear people talk about performance, it's like all they seem to mention is speed. But half the time I go to a website, and maybe there's something loading fast, like the page
appears, but then I try to click a link, and it doesn't work the first three times because it's not a real link, and the JavaScript hasn't hydrated yet, and then on the fourth click I miss entirely because the custom font just loaded, and it's a little bigger than the regular font, so everything shifts down. And then I scroll a little bit, but now there's new images popping in all over, and the whole
layout needs to rearrange to accommodate all of them, all because someone decided to optimize for a time-to-first loading spinner instead of any useful, relevant metrics. So you make it work, you make it right, you make it stable, and then you make it fast. Just like that Kent Beck quote.
So Kent Beck is the guy behind test-driven development. Always thought testing could do with a few more Kents. So first you make it work, you solve the immediate task, you violate any principles or best practices that you need to along the way, and then you make it right.
You handle the edge cases, you find the right APIs, the right component boundaries, so it's as robust and useful as possible. And then you make it fast. So now that you know what the right abstraction is, you can use those constraints to rewrite everything with speed in mind.
That makes a lot of sense at the small level, like at the unit test or the component level. You probably can't build your whole app that way though, because when you need to make it fast, how much of a rewrite are you really willing to tolerate? What if that causes you to rewrite other things? Would you start over completely from scratch?
I'm sure there's some companies that give you unlimited runway to work with here, but I've never worked for one. The more rework that's required to hit that next level of performance, to make it right, to make it fast, less likely you're actually going to have time or budget to do it. More practically, you end up with a ton of tech debt and it never really gets paid down.
So instead, if you pick the right architecture and the right design patterns, you can make those steps much smaller and make performance actually achievable for your product. So what is the right architecture? It depends. Thank you, everyone. I've had a great day. Now I've got more time.
Lots of time here. Let's play bingo. It depends on what your app is going to do, like now or in the future. Are you showing data in a table? How much data? Will users be able to search it? Can they scroll through it infinitely to the last page?
Do you aggregate data from multiple APIs into a single real-time feed? Does your app work offline? Can all your users create new documents or maybe only ones with certain roles? Do they need to appear in the UI instantly?
Does creating them involve transactional guarantees across multiple backend services? You probably don't know all the features your app is ever going to have, but you should get a sense of where you're trying to go here when you're picking out the tech that you're going to be using. So we can start filling these in.
Let's look at pre-rendering. Many of these features aren't affected by pre-rendering at all. That's all the white ones. We can ignore those for now. The two options are SSG and SSR. I don't know why we chose those acronyms. Generation and rendering mean the same thing, so we're disambiguating them based on what
one of the S's stand for. But I like to call them build time and request time pre-rendering, because that makes sense to me, whether rendering at build time or at the user's request. So SSG, static site generation. Oh, I've got my slides over here as well.
SSG, static site generation, where you render each page into a bunch of HTML bundles and throw them on a static file host is okay for features that don't update very often. That's blog posts and change logs. Archive data is good, because you can just generate the files, throw them on long-term
cloud storage, and forget about it until someone comes at you with a GDPR claim. Documentation kind of works as long as you don't mind huge build times. If you have thousands of pages, you're going to be building those thousands of pages every time you deploy, which is a bit of a pain.
Any features that might be out of date by the time the user sees them, you can't pre-render them at build time. Any data visualization, tables, search results, user settings, we need to render those a little later, like on request time, which is back to server-side rendering. Rendering on request means the data is going to be up to date, and we can customize what
we return for each user. Server-side rendering suffers a little bit when it comes to doing things like blog posts or docs, but because they're content-heavy, and you're going to get killed on bandwidth if you're compiling those on every request for every user, unless you put a cache in front of it.
To be honest, your static sites are going to want a cache as well, because S3 will kill you on egress pricing, but caches are good. The cache doesn't care how long ago you rendered the page, whether it's request or build time. Browsers literally can't tell the difference, because it's all HTML to them. I can recommend server-side rendering behind a cache as the best of both worlds.
There's actually no trade-off against static generation, except you need to run a server in order to use it. Server-side rendering is the highest performing way to get HTML to the browser. Second half of the story is all about what happens once the HTML is there. Once it's downloaded your JavaScript assets, how is it actually going to implement those
features? Well, if it's a feature involving data that changes over time, there's two ways you can do it. You can either modify it locally, and push new state to the server, that's local first. Or you can send a request to the server, modify it there, and read the new state when it comes back.
It's a classic server-first request-response model that you're going to see in most applications, and it's much simpler, because you're always working directly with a single source of truth. The trouble here is that each interaction has to cross the network, so there's always that baseline level of slowness that you're going to have to put up with.
You have to work around, but this model is simple, it's stable, but you have to put work into it to make it fast. Local first, on the other hand, directly modifies the UI state, and then in the background, you sync or persist those changes to the database. With that model, you get immediate updates, but if something goes wrong during the sync
step, you're going to have to deal with that, and that can be really tricky to deal with. Building a sync service to make local first manageable is one of the hard problems. You'll probably reach for something like RepliCache, like Alex talked about earlier, and then the developer experience is really nice.
There's strong advantages here for highly collaborative, like multiplayer editing, or for offline mode. All of that comes out of the box with RepliCache, but on the other hand, in order for the user to make changes local first, they need to download enough data to do it.
If you're sorting a page of data server-side, then that just means asking the server for another page of sorted data. Sorting local first means you need to download all the pages so you have enough data to sort it there. There's a lot of overhead enabling those high-performance interactions.
If you have big datasets that don't get used very often, it's probably not practical to replicate that to your users in case they might use it, so for some things, you'll probably want to server-first model anyway. Even though RepliCache works really nicely with Remix, your app might not need to work offline to get its benefits.
Maybe you don't need an app to see your grocery list in the first place. So maybe instead of starting with local first and a sync service and building server-first
solutions to fill the gaps, you can try the simpler model and start with a server-first model and work to fill the gaps in that direction.
So this is what we get out of the box with Remix, with PRPC, React Query, Next.js. It's your typical JavaScript framework setup here. So there's a lot of things that work really well here, forums, dashboards, server validation,
all types of content, wall-based access control, transactions, queues, bulk processing, all of that works great. The CRUD features are a little slow. Those are the ones in yellow here, just because you have that network round trip. But we can look for solutions for that in a moment here.
So in Remix, we implement this layer with loaders, and I was going to talk a lot more about loaders, but Lena just told you how those worked, so this will maybe make my talk come on time. Yeah. They look like this. They're super simple. You return data from the loader, and the page will server-side render using that data.
If you do any mutation, like any post request, all the matching loaders will rerun to make sure they have the latest data. It's configurable. Remix always defaults to keeping you up-to-date with the latest data. Next.js, on the other hand, takes the opposite approach. They use really aggressive caching to minimize server load, which can make sense, but when
things go wrong, problems caused by the server running too often are easier to make sense of than problems caused by all my data being out of sync. So for data that shouldn't block the page, loaders still work. So imagine you have a big dashboard page with graphs and counters, and they require a ton
of expensive queries, but your user doesn't care about that, because they're only on the dashboard to find a link to another page. You wouldn't make them wait the whole time for all that data to load just to click a link and leave the page. So this is the same example from the previous slide.
We've got the same await fetch issues, but above it now we have fetch stats, and we're not awaiting that. So that is still a promise, just a regular fetch, and it's still a promise when it hits the front end as well. And so at the bottom we've got the suspense fallback, and then the await component that
accepts that promise, and as soon as that resolves, the complete UI streams in and it replaces that spinner with the complete data. So by choosing to await or not await the promise, you choose if it's important enough to appear on initial page load, or if we let the page load without it and show a spinner instead. This is great.
This used to be the defer API in Remix, but they went and fixed that in the latest pre-release, so now you just return objects, which is great. So this is only a one-routes loader, but with nested routes you can have several active loaders at once, each feeding data to different parts of the page.
This is on the Remix homepage, on the landing page there. You'll see root sales invoices and invoice ID. Each of these has its own loader. As you navigate between child routes, the loaders re-run. Parent loaders don't. So you can have really fine-grained data hierarchies here. And you can treat the nested routes like context providers.
So any data is accessible in any component rendered within that route without prop-joining. The root loader data is available everywhere, so I like to use that one for the logged-in user, any global preferences that need to be available everywhere. For the other loaders, I make a custom hook that throws an error if someone tries to use
it in the wrong place. And then all your server data is available in any component in your app. I wasn't going to talk about actions much, because I don't think they're that special. They're just post-endpoints. The cool thing is what happens afterwards when it re-validates all your data, but this
is a really useful pattern, so I thought I'd give it a shot. Each request has an intent key that identifies it as create, or create many, or an update. And in your action, you can use a discriminated union to compose all of those together.
So you can just have a block here, and you check the intent value, and inside you get full type information on what that value could be. And you can just compose as many as you want. So you can start off with just one or two. As your app grows, you add more. This scales really well along the lifetime of a project.
And we can do the same thing in the loaders as well, to validate search parameters. If you have five different features, each of which use some search parameters, like pagination, filtering, sorting, each of these can be their own components that independently store their state in the URL. In the loader, we merge them all together and use the values in the database queries.
Every time the search params update, or the user navigates, or there's any action completes, the loader's going to rerun, and all of this stays up to date all the time. This is your global central source of truth. So building on top of the loaders, we can improve the performance of all the read-based
features using a client cache. Now pagination works better here. You can do instant searches locally, any sorting, filtering, any of that you want to do. Any size of table here just becomes possible. The easiest way to do that in Remix is with the new client loader feature.
So client loaders are exactly the same as server loaders, except they run on the client. So Remix expects you to have one or the other, and you can use it with the same loader data hooks. No need to learn anything new there. But the cool thing is if you have a client and a server loader, the server loader runs on initial page load, and the client loader runs afterwards.
The client loader has the power to call the server loader. So you can use this to implement a cache. And then as your app makes requests for the loader data, you get to choose whether it actually hits upstream or not. So imagine you're searching the database, and you're taking a few hundred milliseconds for each query.
As soon as the cache is ready, your latency just drops to zero with instant local searches because it no longer needs to hit the network. And if you don't want to build that yourself, you can use the Remix client cache package, which looks like this one liner at the bottom, cache client loader, and it automatically sets all that up for you.
You can use lots of different adapters, local storage, session storage, IndexedDB, makes it super, super easy to implement that. So that about covers high-performance reads, create, update, delete, they're all mutations, and they all work pretty much the same from the application's point of view.
You can solve them all at once with Optimistic UI. So React was founded on the idea that UI is a function of state. So given a certain state, you get a certain UI. So we can define Optimistic UI as a function of server state plus all pending changes that have been made.
So as long as we have a way of seeing all the changes that are currently in progress, then all the current changes that are being made in the app, all the active submissions, then we can get to this level of UI performance. So here we've got way more green boxes, instant UI updates, creation and editing are fast.
Let's look at how we implement that. So the key is fetchers. Fetchers are my favorite Remix feature. You get global access to every request status, payload, and response. You are looking right here at fully declarative Optimistic UI with rollbacks in just that four lines of code.
So we use fetchers to get all submissions. We filter them down to just get the ones that are submitting, and then we use object from entries to turn the form data into an object. So we can filter to only the submissions for creating an object, because that's what we're
looking at here, and we append that to our list of issues. So the moment you submit a new item from a form anywhere in the app, this code will pick up that submission and add your item to the table before the network completes. Then after the network completes, the loader reruns, these Optimistic items go away, but
they're seamlessly replaced by the new item with the fresh loader data. On the other hand, if the action fails, the item never gets created. Your Optimistic item still goes away, and it just looks like a rollback. You can handle errors any way you want in that case.
So this specific implementation is a little naive. From entries doesn't work on all form data, because some form data can have multiple keys. Some of your fetchers are going to use JSON instead of form data. So I made this little abstraction around useFetchers called useFetchersBySchema.
This one normalizes all of that form data into JSON. It accepts a Zod schema, which I love, and returns all the fetchers that match. So each of these schemas from before is that they have that intent key, so this automatically finds all the submissions that were fired with the same intent.
So now we can take our list of issues from the server, add any we've tried to create, filter out any we've tried to delete, and then map over the rest to apply any changes that we've tried to make, all in a single chained operation. So I've built a lot of CRUD apps, and this is like my dream developer experience. So I don't usually go further than that.
And the apps I tend to work on, we don't really need more collaborative features. They never need to work offline. But if you need to, then you can keep building on top of it to solve any new requirements that come up in the future. So you can say multiplayer UI is server state plus your pending changes plus everyone else's changes.
And you can push those changes to you directly with a WebSocket server, like PartyKit. We could check the last box for offline mode, using a service worker to play the role of the server when there's no internet connection. Those aren't for the faint of heart. They're not very easy to work with.
But there's the excellent RemixPWA package, which is well-maintained, and I think they just released EpicPWA, which implements some of that into the Epic stack. So if you're thinking, like, which of these should I include in the app, I trimmed off the top of this pyramid so it didn't look like the Vercel logo.
So you can start off with the bottom layer. You build your whole app with server loaders. It'll be rock-solid reliable from day one. And then you can go a layer up and improve the performance of your searches and your filtering. And then you just keep stacking the bricks. Use your access to all pending state to get optimistic UI. And if you want to keep going and adding more things, you can.
But at every step of the way, your app feels solid. It's working. It's stable. And if you keep plugging along, you can make it fast, too. So I'd like to give a shout-out to Molten, which is the Remix community. I'm over time, but I'm the last talk, so I'm allowed to be. This is the Remix community newsletter, which I run.
I spend most of my life hanging out in the Remix Discord and the Epic Web Discord, seeing what people are working on and helping where I can. And I see a lot of cool things people are doing. So roughly once a month, I collect them all together, I send them out. Now is a really good time to sign up for this, if you're interested, because I forgot to send out last month's issue.
So the next issue is going to have a lot of content. And you can check that out at readmolten.com or by stealing this QR code right here. I didn't test to see if this was scannable, but I just assume it is. Pretty straightforward. And one more thing. I'm super excited to announce that I am launching a course teaching how to build high-performance
CRUD applications with Remix, Zod, Conform, and the rest of the Epic stack. And that Kent has invited me to join the team of fantastic instructors and turn this into an Epic Web Workshop. So it's in progress right now.
Going to rearrange a bunch, but we're going to build all the tricky parts of an issue tracking app, lesson by lesson, right in the workshop. Everything from simple routing to creating items, server-side validation, client loader caching, fetcher's optimistic UI. Everything from this talk is all directly from the workshop material.
I'm not here to teach you about web fundamentals or how to build your own React server component framework. Kent can do a far better job. I don't even know where Kent is. I just keep motioning that. There he is. OK. Kent can do a far better job of that than I can. I'm just here to apply all that into building better projects.
As Marc Andreessen once said, it's time to build. Thank you so much, everyone. Yeah.