Skip to content

Instantly share code, notes, and snippets.

@Widdershin
Last active March 8, 2024 11:21
Show Gist options
  • Star 108 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save Widdershin/98fd4f0e416e8eb2906d11fd1da62984 to your computer and use it in GitHub Desktop.
Save Widdershin/98fd4f0e416e8eb2906d11fd1da62984 to your computer and use it in GitHub Desktop.
The absurd complexity of server-side rendering

In the olden days, HTML was prepared by the server, and JavaScript was little more than a garnish, considered by some to have a soapy taste.

After a fashion, it was decided that sometimes our HTML is best rendered by JavaScript, running in a user's browser. While some would decry this new-found intimacy, the age of interactivity had begun.

But all was not right in the world. Somewhere along the way, we had slipped. Our pages went uncrawled by Bing, time to first meaningful paint grew faster than npm, and it became clear: something must be done.

And so it was decided that the applications first forged for the browser would also run on the server. We would render our HTML using the same logic on the server and the browser, and reap the advantages of both worlds. In a confusing series of events a name for this approach was agreed upon: Server-side rendering. What could go wrong?

In dark rooms, in hushed tones, we speak of colours. We joke of nulls, commiserate about queues, but of colours we only whisper.

That is because colours can hurt you. We shield our juniors from this knowledge, not because it's the wise thing to do, because once you confront the abyss there's no turning back.

The common colours are no secret. Is a function synchronous or asynchronous? Does it mutate the arguments provided, or meekly return a value? Do I need to worry about it throwing an exception?

In a certain light, colours are merely a way to categorise certain types of functions. Functions of a like colour can be used together without much consideration.

It doesn't take much examination to see that colours beget complexity. Much of the art of application-level architecture is appropriately reducing and grouping colours to oppose this complexity.

In the olden days, we had few colours to worry about. The task of managing incoming connections was abstracted to a simple mapping of request to response.

All functions blocked execution for a while. Some would talk to databases or queues, and we were either content to wait, or to fire and forget while we returned the response.

It's different in the browser. Our code competes with the user's ability to interact, and therefore we must be careful. We embrace asynchronous code, and with it all the complexity that comes from another colour.

Server-side rendering (SSR) poses yet another problem on top of this one. Some functions were not designed to be used in both Node and the browser, and so we find another colour lurking.

In order to support applications that make use of both server coloured functions and client coloured functions, bundlers were built, as clever as they are obtuse.

Say you want to read a file as part of a request. This is only allowed in certain functions, designated by the framework to be server-coloured.

You cannot use a server-coloured function at the top level, since that implies it should be included in your browser, and you cannot use a client-coloured function with the framework's server-coloured functions.

Next.js describes this approach as "smart bundling". This may be smart, but it is not wise.

It's hard enough to ensure that you're only using the correctly coloured function in the right place, until you consider that one of the main advantages of this sort of framework is sharing code across the server and the client.

It's entirely up to you to ensure that you correctly manage your function colours. If you accidentally use the wrong sort of function in your shared code, your application will not work.

If you're lucky, the bundler will catch this and throw a seemingly unrelated error. More likely, you'll experience a cryptic runtime error, followed by days of agony if you don't understand this problem. There is very little available in the way of static analysis to make this easier.

To me, this seems like a very bad idea. What's worse, this important colour is generally treated as a footnote by the creators of these frameworks. Most developers only realise how bad this problem is when it's too late.

I don't think that SSR apps are fundamentally a bad idea. That said, the way we're going about it right now is terrifyingly complex and error-prone.

If you're considering using one of these frameworks, I would recommend you carefully consider if the complexity is worth it, especially for less-experienced members of your team.

For those who do pursue SSR, I advise you to work towards better static analysis to help manage these problems. Rust has built a career by explicitly recognising that lifetimes exist and that we need better tools to work with them. A similar opportunity exists here for SSR apps.

@joshchernoff
Copy link

This is exactly why I'm betting on Elixir and LiveView. I'm so sick of the JS SPA/SSR apps they are nothing but pain.

@lawwantsin
Copy link

lawwantsin commented Apr 20, 2022

The extent of the fake news bubble around SPAs and frameworks is only serving these cloud companies that sell the solution to the slowness problem that they invented. My frustration stems from the same place as the OP. Marketing claims something is fast, no one builds a big app to prove it actually scales linearly in the mid-game. Then we web developers jump on the framework API treadmill and find, low and behold, it doesn't scale. Fast starts on a new project are only the same as productivity if you're a noob or a project manager.

Stop offering your open source quicksand for free and throwing devs the vine if we pay those cloud prices. Stop pushing these frameworks on Jr devs before they learn anything about how fast computers really are.

Devs: You want an evergreen project that scales linearly to millions of pages? Learn how to Roll Your Own.

@rauchg
Copy link

rauchg commented Apr 20, 2022

@lawwantsin I mentioned "mini-apps" because even though we call the directory pages/, each of the "entrypoints" there is equivalent to having a SPA in Next.js, with server-rendering, static generation or plain HTML. Those 2 minutes are accounting for thousands of components, complex pages, type checking, linting and production code optimization. The example provided is from an at-scale company https://lattice.com/.

To substantiate my point, here's an actual "390 pages" (as in HTML) build: https://390-pages.vercel.app/ – which actually takes 2 seconds 😄

next build [2.08s]
▲  390-pages/ (main) time next build                                                                                                                                                       2225ms 
info  - Checking validity of types  
info  - Creating an optimized production build  
info  - Compiled successfully
info  - Collecting page data  
info  - Generating static pages (393/393)
info  - Finalizing page optimization  

Page                                       Size     First Load JS
┌ ○ /                                      370 B          75.7 kB
├ ● /[lawwantsin] (1835 ms)                342 B          75.6 kB
├   ├ /0
├   ├ /1
├   ├ /2
├   └ [+387 more paths]
└ ○ /404                                   193 B          75.5 kB
+ First Load JS shared by all              75.3 kB
  ├ chunks/framework-930808ce1262486e.js   44.9 kB
  ├ chunks/main-09180d36f1fd53fa.js        28.3 kB
  ├ chunks/pages/_app-06585788fa003a72.js  1.36 kB
  └ chunks/webpack-fd82975a6094609f.js     727 B

○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

real    2.08s
user    3.82s
sys     0.88s
pages/[lawwantsin].js
export function getStaticPaths() {
  return {
    paths: new Array(390).fill(0).map((_, i) => ({
      params: {
        lawwantsin: String(i),
      },
    })),
    fallback: false,
  };
}

export function getStaticProps({ params }) {
  return {
    props: {
      id: params.lawwantsin,
    },
  };
}

export default function Page({ id }) {
  return (
    <div>
      <h1>Page {id}</h1>
    </div>
  );
}

(FWIW we think it will get much lower than 2 seconds in the future)

@jeremyw
Copy link

jeremyw commented Apr 20, 2022

For the vast majority of projects, "scales linearly to a millions of pages" is not a requirement. Rolling Your Own is a Waste Of Time.

@lawwantsin
Copy link

@jeremyw you only have to "waste" that time once per career tho.

@suyashcjoshi
Copy link

Ideally, the server should do what it's best at and leave rendering and UI logic to the client. We have taken most client code and put that on the server side which is not the best approach.

@joshchernoff
Copy link

joshchernoff commented Apr 20, 2022

Heres the bottom line, if you don't like what you are doing, you are not likely to do a good job at it.
In short these are subjective problems to the individual. My truth is not your truth, ect.

People need to stop trying to put a fucking label on everyone and just go forge their own paths.
I hate SPAs, but I know so many do great work with them, does not mean SPAs are bad just because I don't like them.
It just means I don't like them.
So I don't do them, problem solved.

@sher
Copy link

sher commented Apr 20, 2022

Hey @oshox – one of the designers of Next.js here. We've also identified the issue you've run into, and we're introducing 🅰️ a filesystem convention (.server.ts) for more cleanly separating client and server component trees, as well as focusing the rendering layer on a 🅱️ Web standards runtime. This makes bundling and DX a breeze, in my opinion.

This work is ongoing and looking really promising already. SSR is getting easier, faster, cheaper, and edgier than ever 😄

I think Remix uses this filesystem separation convention .server.ts and .client.ts, which helps devs explicitly separate concerns.

@Widdershin
Copy link
Author

@rauchg thanks for engaging with this post and sharing upcoming improvements to the SSR story. Out of curiosity, is anything planned to improve UX when a user accidentally calls server/client specific code from a shared function?

@Widdershin
Copy link
Author

Just as an aside for people reading this, please keep your comments civil and considerate. Contempt culture is not welcome here.

@joepuzzo
Copy link

I believe collectively there are many developers that are tired of the promise of a framework solving all their problems. IMO the problem always comes down to complexity. If a system becomes over complex, there are more problems that need to be solved. We need to work on not solving all the problems, but rather, taking a step back and trying to simplify. The JS community has become a confusing mess of frameworks, "standards", and build tools.

.. Don't believe me. Ask 10 different developers how to write a React app. You WILL get 10 different answers. It's ok to get answers that vary slightly ( of course they will ). However, it will more likely vary A LOT.

Why is this sad and annoying... because this is just 10 different devs using React. Not to mention the 700 other ways people can create "modern" web apps. There is little consistency.

@oshox
Copy link

oshox commented Apr 20, 2022

@joepuzzo The complexity goes either into the framework stack or the boilerplate. People maintaining existing sites will obviously have a different perspective, but tapping into the years' of experience that framework maintainers possess is a great experience for new developers.

@joepuzzo
Copy link

I fear we continue to re-invent the wheel. Constantly rebuilding our foundations, preventing us from building upwards. Making it hard for people to share.

@joepuzzo
Copy link

Much like the layers of the OSI model, the ability to build new amazing higher level things is achieved because a layer stabilizes. This stabilization allows us to build upwards. The web layer as it stands is far from stable.

@joepuzzo
Copy link

I wish the years of experience worked towards a more unified direction. It feels to me like people are running in 7 different directions.

ArticleA/PersonA: "Module federation is the future of the web"
ArticleB/PersonB: "Remix is the future"
ArticleC/PersonC: "Next Js is the future"
.... on and on

@charlesponti
Copy link

Agree with @joepuzzo
After years in this industry, I’ve had to relearn how to render and style a button in more frameworks and “best practices” than I can count.

Next.js is awesome. I’ve had a pleasant experience with it and know many others, from big and small companies, who have as well.

But, at the same time, it is frustrating that with the combined genius of the JS community, we’re still debating what color the wings should be instead of flying to Mars.

@joepuzzo
Copy link

@charlesponti Yes! I often Joke with other developers about how many different ways you can put a button on a screen and hook it up. Every time the button looks the same, ultimately does the same thing, yet it's completely different.

Im not saying it's not good to have different options, many are quick to make that argument to me. However, i'm simply asking what is our gain? Is the new way really so much better than the old way... does it REALLY allow me to move quicker and build higher.

I would be interested to see the web community stop throwing around different types of mud as the foundation. "My mud is better than your mud". Would be cool to have a stable foundation that we can build on.

@mjvmroz
Copy link

mjvmroz commented Apr 20, 2022

Occasional whispers of function colors is the only thing keeping me going. Thanks.

@arctic-hen7
Copy link

For an alternative perspective on this, I'm the developer of Perseus, a Rust framework that supports SSG, SSR, incremental rendering, etc. From my experience, a lot of the problems of mixing server-side and client-side code come from ambiguities in dependency management and misunderstandings about how state is passed through an application.

Compiled languages like Rust, while much slower in compiling apps, are often faster at runtime (Perseus scores better than Svelte in some Lighthouse metrics, for example, though Wasm still has a way to go on some DOM optimizations), and they're usually much better at delineating dependencies. For example, I can easily declare a 'crate' (Rust dependency) that's only used on the server, and separate that from one used only on the client.

The big misunderstanding I've found from a lot of my framework's users is with regards to fetching data, and the ambiguities of doing one thing on the server-side and a quite different thing on the client-side. I think that can be partially solved by putting server-side code in a different file, but the important thing is to have clear state flows through apps. You generate state in one place, and then it's processed by the framework, and then you can use it in another place.

Fundamentally, SSR should, in my opinion, be about state generation. It's easy enough to optimize apps to prerender some HTML on the server these days, the tricky part comes when you're figuring out what to load and when (you need to load user-specific information in the browser, product-specific information on the server at runtime with caching, layouts at build-time, etc.). To make sense of that for users, I think framework developers (myself 100% included) have to make what the 'black box' framework is doing more transparent. When people think the data they fetch on the server-side is shared magically with the browser, misunderstandings pop out far too easily. If we can make it clear that the data are serialized, stored as JSON, passed over the network in some way, deserialized, processed, interpolated, made reactive, etc., and if we can communicate that in a beginner-friendly way (still working on that one...), then I think we can start to fix this issue.

The reason a lot of frameworks have these kinds of problems as a footnote is I think because the developers don't see them as a problem. When you've built a system for moving state around, and you understand it, things are clear to you. Then, suddenly someone starts asking about why they can't use a server-side function in a different place, and it's obvious to you because you know how state flows through the app, but not to them, because the documentation treats the framework's internals as something you shouldn't have to worry about. I think that's the information we need to get across to users if we want to solve this problem long-term without making significant infrastructural changes to the existing framework landscape (and, if we were to make such changes, I think, as the comments on this post prove, the best approach is far from universally agreed).

As a side note going to @joepuzzo's point, I think making the internals of frameworks more transparent in documentation would go a long way to fostering a more productive environment in framework development. When we can all learn from each other, and figure out why one framework is faster than another, regardless of our skill level in a particular language, we can hopefully bring one framework's advantages to another framework. The open-source community should be about cooperation, not competition.

@mjvmroz
Copy link

mjvmroz commented Apr 20, 2022

Cool looking framework @arctic-hen7, I'll definitely look more closely at that later. I agree with a lot of that, and I'd certainly appreciate framework authors working to make their model easily understandable.

What I'd personally really like though is the ability to have this kind of thing guaranteed, and in a language like Scala or Haskell that'd be pretty standard. Have you considered representing runtime requirements on the typesystem, allowing users to effectively tag functions as only supporting one, the other, or both with different implementations? Curious to hear your thoughts as someone living and breathing this problem space.

@arctic-hen7
Copy link

Thanks @MiriChan! In Rust, you can very easily gate a function for a compile target with #[cfg(target_arch = "wasm32")], for example. That's used quite extensively in Perseus' internals, but I think it would be good to bring it more to the fore in user-written code -- making those runtime requirements coded into the type system more regularly is something I really want to work on over the coming releases.

As an example, I just changed some code in Perseus' internals so that a particular part of the app setup system that registers the equivalent to index.html only runs a necessary regex replacement on the server-side, which means I don't have to bring the regex crate into the Wasm bundle. That's very clearly notated in the code, and the errors produced from misuse then make reasonable sense.

@shuding
Copy link

shuding commented Apr 20, 2022

Out of curiosity, is anything planned to improve UX when a user accidentally calls server/client specific code from a shared function?

@Widdershin Yep (Next.js team here)! First of all, many of the server/client specific code will fail if calling from a wrong environment, these can be found early during development. And generally, a shared component should try to behave the same in server/client as much as possible and not introduce such a branching.

But still, you can 1) use the typeof window === 'undefined' condition to distinguish the actual code runtime and add browser specific code. This covers most of the cases. And 2), we’re thinking about adding a global constant or environment variable like globalThis.NEXT_SERVER_COMPONENT for any shared component to tell if it’s being treated as a server or client component.

@morsmodr
Copy link

morsmodr commented Apr 20, 2022

As a side note going to @joepuzzo's point, I think making the internals of frameworks more transparent in documentation would go a long way to fostering a more productive environment in framework development. When we can all learn from each other, and figure out why one framework is faster than another, regardless of our skill level in a particular language, we can hopefully bring one framework's advantages to another framework. The open-source community should be about cooperation, not competition.

This! I know for some reason comparison is discouraged but questioning and comparing solutions, techniques is engineering/science 101. I am very curious as to why NextJS went the AngularJS (OG 1.x) route of 'magic'. What were the motivating reasons to go with a brand new set of APIs? Now I have to learn NextJS things like getServerSideProps etc. Remix seems like React of SSR and is promising in the sense that it rests on web fundamentals for the most part with 'add-ons' but that said would be curious to see the limitations of nested layouts. It also makes the dev actually wire up RenderToString/Hydrate or RenderToPipeableStream/HydrateRoot (which NextJS does under-the-hood) so the amount of magic is lower but some magic is still present.

If both frameworks can write out a technical article (which isn't dumbed down magic & cool focused) on what their code does under-the-hood, it would be of massive help for developers who have been programming in the web for around a decade if not more. There is a network chasm diagram with Remix explaining high level, but more technical articles would be awesome. That would be a welcome change in both SSR framework blogs, which solely seem to target only new devs, who have little or no exposure to old school just because of when they entered the field. As a community, our focus shouldn't be on wrapping the eyes of devs with magic but to show them how the magic works and what it does :)

@muellercornelius
Copy link

Just today i joked with my colleagues about the history of the web. Back then, when we used PHP for SSR, the path to SPA over the JAM Stack over ISG (Incremental Page Generation) to full SSR again (with hydration or without ) XD. Everything comes back. Maybe the ideas brought up by PHP werent that bad.

One thing is very sure for me. This giant mess with bundlers, build tools, different environments and other stuff needed, introduces a lot of complexity which isnt going anywhere. We have to live with it and thats completely ok for ME because I really like to dig deep.

For newbies tho this is extremely problematic because nobody of them learns it like this: html --> css --> jQuery --> native js --> sprinkle some vue in it --> and then using a full blown SPA Framework.

Its more like that: Oh cool i want to build a website --> Next, Nuxt, Svelte, Vue, Remix, Gatsby,...

WE have the responsiblity to teach the "correct" way... because only then they can decide which technology is best used for.

That brings me to my last point. All of those technologies are great when used for the right purpose but this decision is only possible if you understand how it works and know the drawbacks.

@karlhorky
Copy link

karlhorky commented Apr 21, 2022

First of all, many of the server/client specific code will fail if calling from a wrong environment

you can 1) use the typeof window === 'undefined' condition to distinguish ... we’re thinking about adding a global constant or environment variable like globalThis.NEXT_SERVER_COMPONENT

@shuding if I'm understanding @Widdershin's original post correctly (although I do think that the point is a bit exaggerated - the benefits far outweigh the drawbacks), I think they are aware of the above, and mean that this is still difficult to deal with, when it fails.

In teaching Next.js to beginner students at @upleveled (and seeing the problems isomorphism / universal JS causes in the wild), I have also experienced this - eg. a runtime call to something that works on the server-side but then fails on the client (or vice versa). Even worse if it causes an intermittent error that only shows up on full page refresh.

Maybe it would be possible to have some kind of static analysis in @next/eslint-plugin-next to try to warn developers about incorrect Node.js vs browser API usage as early as possible - in the editor / linting CI step already. Seems like a larger undertaking though, to identify all of the browser / Node.js APIs and also the "safe" areas of the code - eg. in shared components with getServerSideProps, getStaticProps or getInitialProps...

@joepuzzo
Copy link

It's not fun trying to work in muddied waters.

@gtarsia
Copy link

gtarsia commented Apr 22, 2022

@Widdershin it’s hard to pick a “you’re right/wrong/somewhere in the middle” stance when your post is not including the code (or a more in depth description) that caused your bad experience with ssr.

In my experience ssr in nextjs doesn’t get in the way of the stuff I do. Do I need some code to run only in the client? I should place it inside a useEffect callback without deps. That’s it.

Could my milage have varied? Maybe.

I would argue react hooks has brought me way way more unexpected behavior/performance usage.

Ssr though? Pretty transparent to me, with minor issues here and there. The benefit? The page loads pretty fast.

@man-of-eel
Copy link

its just serving html files mate its not that deep

@karlhorky
Copy link

karlhorky commented Apr 27, 2022

@shuding another example of things that could be focused on a bit more are edge cases for imports and bundling for server-side rendering.

Eg. this issue that I just found with using a file with shared server + client exports in Next.js - this causes bundling of the (unused) server code, leading to the dreaded Can't resolve 'fs'. This seems like a bug to me:

vercel/next.js#36514

Going even a bit further with this, there could be more work done to guide developers into the "pit of success" - more heuristics and development-time messaging to teach them how to do server-side rendering properly, by only accessing client or server variables.

@valdas
Copy link

valdas commented Oct 21, 2022

All those javascript frameworks first create problem then tries to solve claiming they are speedy... OMG
Side note for maintainer of next.js - stop using latest-very-best javascript syntax like ,? .? or the like. Not everyone can upgrade to 200 version of browsers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment