My Experience with Qwik After 6 Months of Development

Feb 27 2024

Qwik is by all means an innovative and a bleeding-edge framework. It sets itself apart from other framework with resumability and extreme lazy loading. It ships (almost) zero Javascript by default and only loads the relevant parts when necessary. The list of new ideas and concepts it brings to the table goes on and on. While it is indeed performant when you look at it from certain perspectives, there are much more than just the loading speed of the website, for example developer experience, bugs and quirks and its ecosystem, and I want to share my own experience working with Qwik.

A bit about myself before I start. I am by no means an experienced developer. Qwik is the first framework I learnt (I have some knowledge about Next JS but I never worked on any large projects with it) after React and I focused mostly on the Front-end. (I have no clue on how the two hundered and more services on AWS work) However, I do have a brief idea on how the data-fetching, routing or caching works with meta-frameworks.

First of all, UI libraries and their meta-frameworks work closely together. When you use Svelte, you will most likely use SvelteKit. Similarly, if you use Solid, you will most likely use Solid Start. Same goes for Qwik, you will use Qwik City as the meta-framework. For React, you have more options, such as React Router, Next JS, Remix, etc. There is a strong connection between UI library and its framework and I will talk about both Qwik and Qwik City in this post.

Signals

Qwik uses signals for reactivity, similar to that of Solid. Let's take a look at a simple counter component.

import { component$, useSignal } from "@builder.io/qwik";
export default component$(() => {
  const counter = useSignal(0);
  return (
    <div>
      <p>Counter: {counter.value}</p>
      <button onClick$={() => counter.value++}>Increase</button>
    </div>
  );
});

counter is a signal and we read the value with counter.value. We can also easliy change the value by directly modifying counter.value which is not possible in Solid (Solid separates reading and mutating values).

Also, the reactivity is very fine grained. When you increase the counter, only the {counter.value} will update, so not even the whole p tag will re-render. I was blown away when I first read the docs about this. The way they achieve this is by wrapping the parts that needs to be updated with comments so Qwik can pinpoint the exact spot to mutate (though they plan to change this behavior in Qwik v2 where even the comments will be more lazily generated).

The "0" which will be updated is wrapped around with comments

When component updates, Qwik will go through the component and build a list of signal subscribers which are the elements that is reading the signal's value. Qwik will notify the subscribers when the signal's value changes.

However, this is where the first gotchas comes in. The fine grained reactivity goes away when you start using function such as toString or map.

import { component$, useSignal } from "@builder.io/qwik";
export default component$(() => {
  const counter = useSignal(0);
  console.log("render");
  return (
    <div>
      <p>Counter: {counter.value.toString()}</p>
      <button onClick$={() => counter.value++}>Increase</button>
    </div>
  );
});

import { component$, useStore } from "@builder.io/qwik";
export default component$(() => {
  const list = useStore([1]);
  console.log("render");
  return (
    <div>
      <p>List: {list.map(() => "*")}</p>
      <button onClick$={() => list.push(1)}>Increase</button>
    </div>
  );
});

Both example will trigger a full re-render when button is clicked.

(useStore will also return a signal, but the reactivity goes into nested properties as well, so the whole object will be reactive.)

The below example which does not inline the reading of signal will also trigger a full re-render.

import { component$, useSignal } from "@builder.io/qwik";
export default component$(() => {
  const counter = useSignal(0);
  const val = counter.value > 3 ? counter.value : 0;
  console.log("render");
  return (
    <div>
      <p>Counter: {val}</p>
      <button onClick$={() => counter.value++}>Increase</button>
    </div>
  );
});

Personally, I feel like despite the component will re-render as a whole in many scenarios, Qwik's signal is very easy to work with and to be honest, I do not see the downsides of re-rendering the entire component except for performance issues.

There was one case I encountered where Qwik fail to subscribe a component to a signal's value update and I have to write {signal.value ? signal.value : ""} instead of {signal.value} to force a full re-render but for the most part, signals worked perfectly fine.

Data Fetching

When I first started, I was persuaded to believe that loading and executing Javascript introduces the most amount of delay when a user first loads a page. However, I soon realize that in reality, most delay comes from the server latency and how your data is fetched, and this is where I think Qwik falls behind other framework.

In Qwik, you can load external data via routeLoader$, post data with routeAction$ and execute server code with server$. You can also use useResource$ which is a wrapper on top of useTask$ that refetches data when signal updates (or do anything async).

To consume data loaded from external data sources, you can do something like this:

import { component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
const useData = routeLoader$(async () => {
  // ... return await some data
});
export default component$(() => {
  const data = useData();
  return (
    <div>
      {data.map((item) => (
        <div>{item.name}</div>
      ))}
    </div>
  );
});

Qwik will call useData no matter if the value is used or not and data is a reactivity value. So if you do an SPA refresh (with useNavigate) and the values update, the content will automatically update which eliminates the need to manually setup client side data fetching which is necessary in frameworks such as Next JS.

Qwik by defaults render in server side initially and serializes all the data needed by all components before streaming everything to the client. It behaves similarly to that of server components in React. However, there is a key ingredient missing in Qwik which is very important to reducing the delay of initial page load: out-of-order streaming. At of the time of writing, Qwik is at version 1.4.5 and it has no support for out-of-order streaming, meaning if you have a component that loads external data in the middle of any other components, you either 1. block until the component fully loads the data and send the whole rendered html to the user, or 2. block but send the html rendered before the component that needs data fetching, or 3. implement client side data fetching and nothing will block on the server side. I often resort to the last option because I want the initial delay to be as small as possible but it also means the fetched data will come at a slow time than 1 and 2. 2 is also not a viable option. Imagine you have an e-commerce site and the shopping cart requires fetching data externally. Sending only part of the html means that the user will only see the stuff before the cart, but not the rest of the page (products and everything).

In the useResource$ docs in Qwik, the onPending attribute is actually slightly deceiving. onPending will only show when the data is fetched again on the client side and works fundamentally different than Suspense in React and Next JS.

In simple words, Qwik cannot stream HTML while fetching data at the same time.

Qwik takes longer to get the fetched data to client in all cases

The diagram would be much more complex in real life but it gives a brief idea on how the data fetching and streaming works in Qwik. In the case of SSG, Next JS would have an even bigger edge thanks to partial prerendering where the CDN can send requests to the server and the server streams back the response, compared to Qwik where the requests have to be sent from the user to the server (client side data fetching).

Overall though, the developer experience is fantastic and it is very easy to work with data but fetching data in Qwik will have a higher latency.

Resumability

Let's talk about where Qwik shines. Qwik excels at bundle splitting and lazy loading. On initial page loads Qwik only executes the Javascript necessary to 1. register events 2. register service workers and preloads any necessary codes for the events (loading JS is usually faster than executing). It does not even execute its own framework until any event fires.

Everything in Qwik is event-based. When an event fires, like clicking on a button, the Javascript required to handle the click only will be loaded and executed. Anything else not required is not executed. Qwik achieves this by aggressively splitting the codes that is marked with a dollar sign $. You can almost think that any code inside of $ will be in a new file. When needed, the file will be loaded. It's that simple. Qwik also handles the caching and deduping of the files so you don't need to worry any of it.

Sometimes the code inside of $ needs data from outside of it and the data needs to survive the transfer from the server to clients, and that it why signal values needs to be serializable, i.e. can be converted into strings and sent alongside in the HTML. Classes for example are not serializable and therefore should only be created on the client side where you can use noSerialize to store in signals.

The Qwik team published an excellent blog post about how resumability in Qwik work in details and I highly recommend you to take a look if you would like to dig deeper.

There is one issue that you might imagine. If the codes are loaded on-demand, wouldn't there be latency when users click on a button for the first time? That is very true and Qwik has implemented Speculative Module Prefetching to solve that issue. In production mode, Qwik will keep anticipating the Javascript needed to handle the next couple events fired by the user, such the codes needed to handle a button click, a SPA navigation, etc. This eliminates the delay and make everything snappy and fast. However, there are some odd scenarios where this did not work as expected and I will go over them in the next section.

Bugs and Quirks

There are a couple bugs that I have encountered that breaks the entire app and is hard to debug.

Still remember the server$ function I mentioned? It has similar behavior to that of "use server" in Next JS and executes anything inside of it in the server.

import { server$ } from "@builder.io/qwik-city";
const serverCode = server$(() => {
  console.log("inside of server")
})
serverCode()

There is a major bug where if you have a self-invoking function, or import a server function that is not in the same file, it will throw an error of 500 (Internal Server Error) Uncaught (in promise) Error: Dynamic require of "_.js" is not supported in production and it was super frustrating to debug when I was not aware of the bug.

import { $, component$ } from "@builder.io/qwik";
import { server$ } from "@builder.io/qwik-city";
export default component$(() => {
  const handeClick = $(() => {
    server$(() => {
      console.log("inside of server")
    })()
  })
  return (
    <div>
      <button onClick$={handeClick}>button</button>
    </div>
  );
});

and

import { $, component$ } from "@builder.io/qwik";
import { server$ } from "@builder.io/qwik-city";
import { serverCode } from "./serverCode"
export default component$(() => {
  const handeClick = $(() => {
    serverCode() // server$(...)
  })
  return (
    <div>
      <button onClick$={handeClick}>button</button>
    </div>
  );
});

both fails in production. I am not too sure how server$ is implemented but I suspect that it behaves similarly to routeLoader$ which needs to be re-exported in the same route if declared elsewhere. This bug is particularly painful to deal with because the error message made no sense and only occurs during production.

The other bug I encountered is an edge case where export a string larger than ~50000 characters will make the Qwik bundler fail to build. For particular reasons, I need to export a really large svg as string (I was building a content editor and need to serialize all the content, more about this later). Again, everything works fine in dev mode and no errors pop up. Not until I pushed the code and Vercel starts building did I realize something went wrong. The build takes forever and never completes, but still no error showed up. I tried to build on my Windows machine, and it works fine as well and I was puzzled. Then I tried to build in WSL2 and the build is failing with some random Rust and wasm errors. I almost deleted the entire content editor trying to figure out the problem until I saw a post in Qwik's Discord that someone else faced the same problem. Though this is an edge case and I never ran into the same issue again, it is a good thing to bear in mind.

For the final bug/quirk I had with qwik: it is about how the service worker registers and prefetches the Javascript needed. In production mode, normally the service worker registers when you first loads the site and then prefetches the symbols according to some strategies. Suppose we have the following code:

import { component$ } from "@builder.io/qwik";
export default component$(() => {
  return (
    <div>
      <h1>Home</h1>
      <button
        onClick$={() => {
          console.log("clicked");
        }}
      >
        Click
      </button>
    </div>
  );
});

We have a button that prints out clicked on click.

Next, we need to perform static analysis to determine which file the click listener is in.

We first add the vercel edge adapter and then run npm run build

Inside of .vercel/output/static/build, we can see all the generated files. Going inside each one, we can see that q-YccESKtn.js contains the listener.

q-YccESKtn.js
const o=()=>{console.log("clicked")};export{o as s_XoH0eOG14zI};

It is important to know that re-building the exact same app will lead to the exact same symbols being generated so we can safely assume that this file should be prefetched in a production site.

However, in actual testing, I don't see the file being prefetched on initial load in my chrome browsers.

Service Worker getting loaded in the end and necessary code is not prefetched

Clicking on the button for the first time results in visible delay.

Visible Delay on click

The network logs shows us that the file is loaded after clicking.

The console log takes 250ms to load

Clearing the service worker and refreshing the page again, however, shows a normal result of the file being prefetched.

Service worker prefetches q-YccESKtn.js

Clicking on the button now logs instantly with no delay.

This bug baffles me and I still have not found a fix for it. It seems to only happen when I have chrome extensions installed but I have no idea how chrome extensions would interfere with it. However, without a fix, newcomers who face the same issue will feel significant delay to all the buttons they click and I hope a fix can be found soon.

Conclusion

All in all, I enjoyed developing with Qwik but there is not another framework that I have used long enough that I can compare to. The dev server remains relatively fast throughout my entire project and managing UI and states are a breeze, though I would love to see a qwik library that handles caching and state management like React Query. Things like automatic type inferring from routeLoader$ make development painless. However, there are still many things Qwik lacks and suffers at the moment: typed routing is experimental, lack of out-of-order streaming, lack of adapters for deploying serverless instead of edge, weird bugs and quirks that made it less fun when working with Qwik. Qwik is still at its early stages at the time of writing and I can definitely see it in a much better shape given time, and I would honestly recommend anyone to give it a try, build something with it, but personally, I would want to try out other frameworks after this project and to see what frameworks have to offer.

Ongoing Project

Speaking of projects :D, I have been using Qwik to build an online e-learning platform called Partialty (it is my final year project). We would like to introduce different web libraries and meta-frameworks in a beginner friendly way to encourage more people to dive into the world of web development. There are lots of resources online but I feel like most of them are either too general (includes way more than just web dev), or too specific, like docs. I want to provide a middle ground that is specific to web enough, but without the jargons and densely packed paragraphs that are difficult to understand. Partialty will also be a CMS and an online code playground. We intend to let users create their own courses and manage their own students.

The website is still in its development stage and I am not sure about its official release date (the deadline of the project is end of April) but I will definitely work hard to realise the website. If you want to know more or even help out, please reach out to [email protected].

Last updated: Feb 27 2024

Tags :