A Look at Apollo, From a Relay Perspective

Jeremy Gayed
On Frontend Engineering
13 min readApr 4, 2017

--

Our team is hard at work on implementing an isomorphic (or, “universal”) application that we’re slowly rolling out. While the complexities introduced by this isomorphism are non-trivial, we felt the benefit outweighed the cost — we were keen on keeping TTFP low, ensuring that there are no issues with SEO (even though there’s mixed thoughts on to what extent fully JavaScript-driven apps actually perform from an SEO perspective) and we wanted to still be able to take advantage of full-page caching where it made sense to. We were also attracted to the ease of which React could be rendered server-side and then pick up from where it left off on the client-side, so we were in.

As part of this new front-end stack, the back-end was also going through changes. We knew we didn’t want to continue hitting REST-based APIs where the mix of /v1 and /v2 endpoints were only growing. We decided to jump in on the GraphQL bandwagon.

At the time, the de facto standard for talking to GraphQL servers on the frontend was (is) Relay. So we didn’t think twice about starting there. However, because our app needed to be isomorphic, there were some non-trivial challenges that came along with that decision. Luckily, there were a few modules available to address these issues and help us render our Relay-based app isomorphically: isomorphic-relay and isomorphic-relay-router. If you’re new to this type of stack, the names of those modules alone might raise some eyebrows. But, they were working and they seemed solid as we started building out our prototype. We were also encouraged by the fact that the super smart author behind those modules, Denis Nedelyaev, was contributing code back into Relay.

If you’re only interested in the upshot of all of this, see the TL;DR at the end of this post.

Mo’ Code, Mo’ Problems

As our app prototype started to gradually morph into a full-fledged production-level application (with more teams across the org starting to contribute), the challenges with writing an isomorphic application started to present themselves. We had established a number of patterns that were working, so the pain wasn’t spread throughout the teams contributing, but it did feel like one of those nagging itches that kept coming back.

Just for some context, here is what a typical route in our app looks like:

<Route
path="/imageviewer/*"
getComponent={importImageViewer}
getQueries={({ location }) => (location.state ?
{ image: () => Relay.QL`query { node(id: $id) }` } :
{ image: () => Relay.QL`query { asset(id: $id) }` })
}
prepareParams={({ splat, ...otherParams }, { location }) => {
const id = (location.state && location.state.relayId) ?
location.state.relayId : `imageviewer/${splat}`;
return { id, ...otherParams };
}}
render={({ error, props, element }) => {
if (error) {
return <InternalError />;
} else if (props) {
return React.cloneElement(element, props);
}
return <LoadingPage />;
}}
/>

So, I started looking into some alternatives to Relay. The biggest player here is arguably, Apollo. I was quite impressed with the list of features touted on their site — not the least of which was server-side rendering (SSR) out of the box. That means SSR was a first-class citizen, which I was pretty stoked about. Plus, their documentation was absolutely superb, which is critical for a library that would be at the core of our application as we scaled out to more developers.

Apollo vs. Relay

The following is a write-up I initially did internally to my team that I wanted to share out in case others are looking for more comparisons on these two excellent libraries. Keep in mind that this list is in no way exhaustive, nor is it necessarily 100% accurate — that is, there may be (rather, likely) cases where it’s just user error on our part (e.g. we didn’t write the Relay code completely idiomatically). It’s also in no particular order. So, take what’s here with a grain of salt, but please feel free to comment or call things out. I’m much more interested in learning than I am about calling one library or the other a “winner” (spoiler: there is no winner, the “right” library is the one that works for your use case).

Here goes…

Removing the complexity around isomorphic-relay and isomorphic-relay-router

The isomorphic-relay and isomorphic-relay-router modules are written by a single developer. While that developer was able to contribute things back into Relay for better integration with these modules, the concern is that these are modules we rely on with a relatively small community around them (e.g. in contrast to the Apollo community).

In addition, we’ve come across a couple of cases where we were not able to easily do one thing or another because of these modules. One example is we were unable to experiment with a preact alias because it was incompatible with these modules.

Loading state information provided to the component

A loading prop is provided to the component so that the component itself can handle how it should indicate loading progress. This gives us very granular control over how different components present loading state to the user.

render() {
const { loading } = this.props.data;
if (loading) {
return <LoadingPage />; // or, LoadingSpinner etc.
}
// ...
}

In our application, we had been handing this at our routing layer, this means our routing was doing much more than just pure routing logic, and it was done very coarsely. While a similar pattern might be possible with Relay’s pendingVariables, the mental model of a simple loading prop at the component-level seems simpler from a developer’s perspective. It was also unclear if pendingVariables still apply in the isomorphic code path, but this could have been a user-error case :)

Error information provided locally to the component

Similarly to the loading flag above, errors are provided in an error prop to the component itself (this.props.data.error[]). This means that error handling can be done directly in the component and does not need to happen at the route level. Which means that we can still render our shell even if some query deep in the component hierarchy results in some error.

Again, this is another case we were handling at the routing layer, which means it was done very coarsely. The benefit of moving this to the component layer means we do not have to error-out the entire app and can appropriately compartmentalize error handling at the individual component level.

Note that this is for query errors. React’s Error Boundaries (expected to land as part of React Fiber) is handling a different class of errors (errors thrown within a components render() block, for example). It might be that in future versions, Relay takes advantage of React’s Error Boundaries, but that isn’t possible as of this writing.

Skipping queries for SSR

This is by far the biggest win on Apollo’s side. It’s features like these that show the benefit of going with a library that handles server-side rendering as a first-class feature.

Skipping queries during SSR can be done simply by passing in an ssr: false flag into the query options. Our use case for this was in a <WithUserContext /> component that we didn’t want done server-side (for caching concerns) and only need it to run on the client:

@graphql(gql`query UserQuery($token: String) {
user(token: $token) {
id
entitlements
}
}
`, {
options: {
// Don't run this query on the server
ssr: false, // Look how easy!
variables: {
token,
},
},
})
export default class WithUserContext extends Component { ... }

We have yet to find an appropriate solution to this problem in Relay. Unfortunately, to this day, we are still making this extraneous request server-side (which means we’re doing it twice for every request).

No need to leak queries up to the route level

Currently, our <Shell /> component (a top-level component) defines a root query because of the way isomorphic-relay-router works. This is arguably a leaky abstraction, as the Shell itself does not require any GQL data. Neither does the <Masthead /> component rendered within it. Only when we get to the <UserModal /> component (that’s rendered within Masthead) is the GQL query useful.

<Shell>
<Masthead>
<UserModal user={userData} /> /* GQL data needed */
</Masthead>
</Shell>

In our current app, we’ve had to “leak” this query up to the root component, <Shell />, which essentially breaks the co-located queries with the components that use them goal.

With Apollo, <Shell /> and <Masthead /> can stay pure, and the User query can stay local to <UserModal /> where it belongs.

This also means being able to render the Shell even if there is an error in the User (or any other) query. It also means we can share the Shell component with other React-based apps that do not use Relay for data-fetching.

Built on top of Redux

We’re holding back on pulling the trigger to introduce a Redux layer into our app for as long as possible. But, introducing a Redux layer, if desired, is a smaller ask with Apollo since its data store is Redux. This means we can take advantage of the same dehydrate/rehydrate step for all of our apps data needs moving forward instead of introducing a second one for a Redux store when using Relay. Apollo provides documentation on how to integrate with an app-specific Redux store.

SSR out of the box

Again, since Apollo is built with isomorphic apps in mind, it supports SSR out of the box. It works by doing a ‘virtual’ render of the app on the server to collect all the queries for the given route, then initializes a Redux store (since Apollo is built on Redux) which is dehydrated on the server then rehydrated on the client. I’m not entirely sure how isomorphic-relay does its query collecting and if it also includes a virtual render or not, but I was concerned that this may negatively impact performance so I ran some ab tests locally and it appears (*unscientifically) that there is not much of a performance impact — in fact, it appears to perform better in comparison.

Captured ab numbers below. This is for the same route with the same data, all other routes were commented out. *Unscientific because this was done on my local machine and not against some dedicated performance test cluster.

relay

Time taken for tests:   6.842 seconds
Complete requests: 100
Failed requests: 0
Total transferred: 19907700 bytes
HTML transferred: 19888600 bytes
Requests per second: 14.62 [#/sec] (mean)
Time per request: 68.420 [ms] (mean)
Time per request: 68.420 [ms] (mean, across all concurrent requests)
Transfer rate: 2841.46 [Kbytes/sec] received

Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 55 68 16.8 64 187
Waiting: 54 67 16.8 63 186
Total: 55 68 16.8 64 187

Percentage of the requests served within a certain time (ms)
50% 64
66% 65
75% 70
80% 71
90% 82
95% 91
98% 150
99% 187
100% 187 (longest request)

apollo

Time taken for tests:   6.270 seconds
Complete requests: 100
Failed requests: 0
Total transferred: 16914300 bytes
HTML transferred: 16895200 bytes
Requests per second: 15.95 [#/sec] (mean)
Time per request: 62.700 [ms] (mean)
Time per request: 62.700 [ms] (mean, across all concurrent requests)
Transfer rate: 2634.44 [Kbytes/sec] received

Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.1 0 1
Processing: 52 62 8.4 60 95
Waiting: 51 61 8.3 59 93
Total: 52 63 8.4 60 95

Percentage of the requests served within a certain time (ms)
50% 60
66% 62
75% 64
80% 65
90% 74
95% 86
98% 94
99% 95
100% 95 (longest request)

Normalized object cache

By default, Apollo tries to use the shape of a query itself as a cache so that disparate parts of the app can take advantage of data fetched elsewhere (e.g. think of the use case where User data is grabbed in <UserModal /> and also in <WithUserContext />). This is probably fine in most cases, but in cases where we know this may not work we can manually create an object cache key, more info here.

The difference here with Relay is that the object ID is the default for Relay queries (which is why there’s Relay influence in the schema on the GraphQL server, which wouldn’t be necessary for Apollo). We could chose to continue using the Relay influenced schema and reuse the Relay-ID, or define our own object IDs client side depending on the use cases.

The difference here is probably inconsequential, but worth noting.

Mutation queries update store state

Mutation queries in Apollo also update the store state, so there’s no explicit “fat query” required to keep the UI consistent when doing a mutation query as is the case with Relay (although the Apollo docs do recommend to include fields that could be affected by the mutation in the query). Relay2 is supposed to solve this problem better but not sure what details are available or how that will work quite yet.

The difference here may be two sides of the same coin though. We haven’t written many (read: any) mutations in our app yet, so we’re not sure of the subtleties.

Supports decorators out of the box

Apollo provides support for the @decorator() syntax out of the box. I’m a big fan of decorators. Although to be fair, adding it ourselves for Relay was easy enough, but it meant that depending on if a developer was writing a React class or a stateless functional component, they’d want to import two different things for Relay (our @withRelay decorator or react-relay respectively), which could be confusing.

Prefetch out of the box

This is a real use case for us, and I was quite impressed to see support for pre-fetching supported out of the box and how easy it is to take advantage of with Apollo.

Prefetching is just a function call, which means we can do it virtually anywhere/at any time that it makes sense to. E.g. we could prefetch common user flows immediately after mounting on the client, or when the user hovers over a certain link, etc. It’s not clear how the same can be achieved in Relay.

PropType validation

While we’ve previously decided we do not need PropType validation on Relay-backed proptypes (since we deemed them redundant with the GraphQL schema backing), Apollo has a graphql-anywhere package that provides proptype validation based on the GQL query automatically. This means developers do not need to manually write these proptypes and we can take advantage of them when sharing GQL-backed components. This package maybe(?) useable with Relay as well, so it might be a push here.

Preact alias, hard dependencies on some libraries

By removing isomorphic-relay et al this reopens the door for using the Preact alias for React. We had initially tried out a handful of things to see if we could squeeze out some performance wins preemptively, but this was one of those things we were unable to do because of the isomorphic tools that we needed to support Relay.

We’re also unable to update to the new react-router v4 because of the hard dependencies the isomorphic tools have on how RRv3 works. Not a huge deal since the current routing solution works, but it’s certainly a concern since as the community progresses, we wouldn’t want to pin to an older version of a core library we rely on for support reasons, documentation, etc.

Apollo devtool

Apollo also has a really nice Chrome devtool extension, which provides insight into the queries executed by the app, the data cache and even a Graphiql instance. I believe there is a Relay tab in the React devtools but I could not get it to work locally for whatever reason.

Persisted Queries

The Apollo ecosystem also adds support for Persisted Queries.

This has a few benefits, including:

  • Whitelisting queries
  • Minimize bandwidth usage between client/server (since query IDs are transferred over the wire instead of the entire query)

The overhead here is in how we synchronize this with the server, how we have our GraphQL server understand and ingest this statically provided information at app build-time.

Read more about persisted queries here: Persisted Queries
And here’s the persisted query module: persistgraphql

There’s an RFC to add something similar for Relay.

Native teams are exploring Apollo

Our Native teams are also looking into using Apollo (iOS and Android teams). They have mentioned already contributing caching strategies back into apollo-client which we theoretically should be able to take advantage of. The main benefit here for us is to be in the same data-fetching ecosystem as other teams across the org.

Drawbacks

It’s not all rosy for Apollo, of course. With everything, there are drawbacks and tradeoffs, some listed below.

A tangential ecosystem

The drawback with using Apollo is that it’s a library not written by Facebook. So there may be some challenges when either Apollo or Facebook update their APIs as the two projects progress.

Static analysis done differently

Currently, the way Apollo does static analysis at build time is to define queries in separate .gqlor .graphql files and then use the webpack loader provided by graphql-tag/loader. There’s pros and cons to this approach.

The Apollo team is also working on babel-plugin-graphql-tag which will function similar to Relay’s. The issue to track this work is here: apollographql/graphql-tag#31

One thing to note is that there is a trade-off with doing this at build time. At build-time means a potentially larger JS bundle which can negatively impact TTP/TTI. And of course doing it at runtime can hamper overall page performance. Ideally we’d find the sweet spot and figure out the best way to do it, but it certainly would be nice to have the option either way.

What’s Next?

Since it seems Relay2 is “right around the corner” we thought it best to see what that looks like before deciding on making the switch or not. One thing to note, however, is that Relay2 will still not support SSR out of the box. So the consideration will be on whether Relay teams’ goal of making it “easy for the community to build upon [Relay’s API] to make a server rendering module for Relay” pans out or not.

TL;DR

If you’re building an app that needs to server-side render and talks to a GraphQL server, strongly consider Apollo before jumping straight into Relay; the complexity otherwise is considerable. If SSR is not important to you, Relay is probably a good choice here since you’ll be staying within the “Facebook ecosystem” of tools and modules.

Are you building a React app and decided between Apollo or Relay? Would love to hear how you made your decision, or if you think there’s any more items we should take into consideration that I didn’t mention above. Thanks!

--

--

Coptic Orthodox Christian. Lead Software Engineer @nytimes. Lover of all things JavaScript 🤓