I was a bit out of touch with modern front-end development. I also remembered articles about web bloat, how the average web page size was approaching several megabytes!
So all this time I was living under impression that, for example, if the average web page size is 3 MB, then JavaScript bundle should be around 1 MB. Surely content should still take the majority, no?
Well, the only way to find out is to fuck around. Let’s do a reality check!
I’m writing this in 2024, so maybe do a sequel in a few years?
Why only JavaScript? Content varies a lot from site to site (surely videos on YouTube are heavier than text messages on Slack), but JavaScript is a universal metric for “complexity of interactions”.
The main goal is to evaluate how much work the browser has to do to parse and execute code.
To set some baseline, let’s start with this blog:
The number here would be 0.004 MB. I also highlighted all the important bits you need to set if you decide to reproduce this at home.
Okay, let’s start with something simple, like landing pages/non-interactive apps.
A normal slightly interactive page looks like this — Wikipedia, 0.2 MB:
Slightly bloated — like this — Linear, 3 MB:
Remember: that’s without images, or videos, or even styles! Just JS code.
A bad landing page looks like this — Zoom, 6 MB:
or like Vercel, 6 MB:
Yes, this is just a landing page. No app, no functionality, no calls. 6 MB of JavaScript just for that.
You can do a lot worse, though — Gitlab, 13 MB:
Still just the landing.
Nothing simpler than showing a static wall of text. Medium needs 3 MB just to do that:
Substack needs 4 MB:
Progress?
Quora, 4.5 MB:
Pinterest, 10 MB:
Patreon, 11 MB:
And all this could’ve been a static page...
When your app’s interactivity is limited to mostly search. Type the query — show the list of results. How heavy is that?
StackOverflow, 3.5 MB:
NPM, 4 MB:
Airbnb, 7 MB:
Booking.com, 12 MB:
But Niki, booking is complicated! Look at all this UI! All these filters. All these popups about people near you stealing your vacation!
Okay, okay. Something simpler then. Google. How about Google? One text field, list of links. Right?
Well, it’ll cost you whooping 9 MB:
Just to show a list of links.
Google Translate is just two text boxes. For that, you need 2.5 MB:
ChatGPT is one text box. 7 MB:
I mean, surely, ChatGPT is complex. But on the server, not in the browser!
Loom — 7 MB:
YouTube — 12 MB:
Compare it to people who really care about performance — Pornhub, 1.4 MB:
I guess audio just requires 12 MB no matter what:
SoundCloud:
Spotify:
Okay, video and audio are probably heavy stuff (even though we are not measuring content, just JS, remember!). Let’s move to simpler office tasks.
Google Mail is just (just!) 20 MB:
It’s a freaking mailbox!!! How on earth is it almost as big as Figma, who ships entire custom C++/OpenGL rendering for their app?
And if you are thinking: mail is complicated, too. Lots of UI, lots of interactivity. Maybe 20 MB is okay?
No!
Just no. See, FastMail, same deal, but only 2 MB. 10× less!
Okay, maybe e-mail is too complicated? How about something even simpler? Like a TODO list?
Well, meet Todoist, 9 MB:
Showing you a list of files in folders requires 10 MB in Dropbox:
List of passwords? That’ll be 13 MB on 1Password:
Cards? Add 0.5 MB more, up to 13.5 MB. Trello:
Okay, maybe TODO lists are too complex, too? How about chatting?
Well, Discord needs 21 MB to do that:
Okay, document editing is hard, right? You have to implement cursor movement, synchronization, etc.
Google Docs, 13.5 MB:
Something simpler? Notion, 16 MB:
The typical size of code that social networks need for like buttons to go brrr is 12 MB.
Twitter, 11 MB:
Facebook, 12 MB:
TikTok, 12.5 MB:
Instagram is somehow bigger than Facebook, despite having like 10× less functions. 16 MB:
LinkedIn. Is it a blog? A platform? It has search, it has messaging, it has social functions. Anyways, that’ll be 31 MB:
By the way, I'd like to add you to my professional network on LinkedIn.
Sometimes websites are so stupidly, absurdly large that they deserve their own category.
Here, Jira, a task management software. Almost 50 MB!
Do they ship the entire Electron compiled WASM or what?
But that’s not the limit! Slack adds 5 more MB, up to 55 MB:
Yes, it’s a chat. You know, list of users, messages, reactions. Stuff we did on raw HTML, even before JS was invented?
That’s 55 MB in today’s world. It’s almost like they are trying to see how much more bullshit can they put in a browser before it breaks.
Finally, this blew my mind. Somehow react.dev starts with a modest 2 MB but as you scroll back and forth, it grows indefinitely. Just for fun, I got it to 100 MB (of JavaScript!), but you can go as far as you like:
What is going on there? Even if it unloads and downloads parts of that blog post, how is it growing so quickly? The text itself is probably only 50 KB (0.05 MB).
UPD: It has been brought to my attention that this behavior is not, in fact, representative of normal user experience. Normally embedded code editors will be cached after first load and subsequent loads will be served from disk cache. So as you scroll, you will see no network traffic, but these 100 MB of JS will still be parsed, evaluated and initialized over and over as you scroll.
Look how cute! In 2015 average web page size was approaching shareware version of Doom 1 (2.5 MB):
Well, in 2024, Slack pulls up 55 MB, the size of the original Quake 1 with all the resources. But now it’s just in JavaScript alone.
For a chat app!
To be honest, after typing all these numbers, 10 MB doesn’t even feel that big or special. Seems like shipping 10 MB of code is normal now.
If we assume that the average code line is about 65 characters, that would mean we are shipping ~150,000 lines of code. With every website! Sometimes just to show static content!
And that code is minified already. So it’s more like 300K+ LoC just for one website.
But are modern websites really that complex? The poster child of SPAs, Google Maps, is quite modest by modern standards — is still just 4.5 MB:
Somebody at Google is seriously falling behind. Written with modern front-end technologies, it should be at least 20 MB.
And if you, like me, thought that “Figma is a really complex front-end app, so it must have a huge javascript download size”, well, that’s correct, but then Gmail is about as complex as Figma, LinkedIn is 1.5× more complex and Slack is 2.5× more ¯\_(ツ)_/¯
It’s not just about download sizes. I welcome high-speed internet as much as the next guy. But code — JavaScript — is something that your browser has to parse, keep in memory, execute. It’s not free. And these people talk about performance and battery life...
Call me old-fashioned, but I firmly believe content should outweigh code size. If you are writing a blog post for 10K characters, you don’t need 1000× more JavaScript to render it.
This site is doing it right:
That’s 0.1 MB. And that’s enough!
And yet, on the same internet, in the same timeline, Gitlab needs 13 MB of code, 500K+ LoC of JS, just to display a static landing page.
Fuck me.
]]>Do you love interactive development? Although Clojure is set up perfectly for that, evaluating buffers one at a time can only get you so far.
Once you start dealing with the state, you get data dependencies, and with them, evaluation order starts to matter, and now you change one line but have to re-eval half of your application to see the change.
But how do you know which half?
Clj-reload to the rescue!
Clj-reload scans your source dir, figures out the dependencies, tracks file modification times, and when you are finally ready to reload, it carefully unloads and loads back only the namespaces that you touched and the ones that depend on those. In the correct dependency order, too.
Let’s do a simple example.
a.clj:
(ns a
(:require b))
b.clj:
(ns b
(:require c))
c.clj:
(ns c)
Imagine you change something in b.clj
and want to see these changes in your current REPL. What do you do?
If you call
(clj-reload.core/reload)
it will notice that
b.clj
was changed,a.clj
depends on b.clj
,c.clj
but it doesn’t depend on a.clj
or b.clj
and wasn’t changed.Then the following will happen:
Unloading a
Unloading b
Loading b
Loading a
So:
c
wasn’t touched — no reason to,b
was reloaded because it was changed,a
was loaded after the new version of b
was in place. Any dependencies a
had will now point to the new versions of b
.That’s the core proposition of clj-reload
.
Here, I recorded a short video:
But if you prefer text, then start with:
(require '[clj-reload.core :as reload])
(reload/init
{:dirs ["src" "dev" "test"]})
:dirs
are relative to the working directory.
Use:
(reload/reload)
; => {:unloaded [a b c], :loaded [c b a]}
reload
can be called multiple times. If reload fails, fix the error and call reload
again.
Works best if assigned to a shortcut in your editor.
reload
returns a map of namespaces that were reloaded:
{:unloaded [<symbol> ...]
:loaded [<symbol> ...]}
By default, reload
throws if it can’t load a namespace. You can change it to return exception instead:
(reload/reload {:throw false})
; => {:unloaded [a b c]
; :loaded [c b]
; :failed b
; :exception <Throwable>}
By default, clj-reload will only reload namespaces that were both:
If you pass :only :loaded
option to reload
, it will reload all currently loaded namespaces, no matter if they were changed or not.
If you pass :only :all
option to reload
, it will reload all namespaces it can find in the specified :dirs
, no matter whether loaded or changed.
Some namespaces contain state you always want to persist between reloads. E.g. running web-server, UI window, etc. To prevent these namespaces from reloading, add them to :no-reload
during init
:
(reload/init
{:dirs ...
:no-reload '#{user myapp.state ...}})
Sometimes your namespace contains stateful resource that requires proper shutdown before unloading. For example, if you have a running web server defined in a namespace and you unload that namespace, it will just keep running in the background.
To work around that, define an unload hook:
(def my-server
(server/start app {:port 8080}))
(defn before-ns-unload []
(server/stop my-server))
before-ns-unload
is the default name for the unload hook. If a function with that name exists in a namespace, it will be called before unloading.
You can change the name (or set it to nil
) during init
:
(reload/init
{:dirs [...]
:unload-hook 'my-unload})
This is a huge improvement over tools.namespace
. tools.namespace
doesn’t report which namespaces it’s going to reload, so your only option is to stop everything before reload and start everything after, no matter what actually changed.
One of the main innovations of clj-reload
is that it can keep selected variables between reloads.
To do so, just add ^:clj-reload/keep
to the form:
(ns test)
(defonce x
(rand-int 1000))
^:clj-reload/keep
(def y
(rand-int 1000))
^:clj-reload/keep
(defrecord Z [])
and then reload:
(let [x test/x
y test/y
z (test/->Z)]
(reload/reload)
(let [x' test/x
y' test/y
z' (test/->Z)]
(is (= x x'))
(is (= y y'))
(is (identical? (class z) (class z')))))
Here’s how it works:
defonce
works out of the box. No need to do anything.def
/defn
/deftype
/defrecord
/defprotocol
can be annotated with ^:clj-reload/keep
and can be persistet too.clj-reload.core/keep-methods
multimethod.Why is this important? With tools.namespace
you will structure your code in a way that will work with its reload implementation. For example, you’d probably move persistent state and protocols into separate namespaces, not because logic dictates it, but because reload library will not work otherwise.
clj-reload
allows you to structure the code the way business logic dictates it, without the need to adapt to developer workflow.
Simply put: the fact that you use clj-reload
during development does not spill into your production code.
The simplest way to reload Clojure code is just re-evaluating an entire buffer.
It works for simple cases but fails to account for dependencies. If something depends on your buffer, it won’t see these changes.
The second pitfall is removing/renaming vars or functions. If you had:
(def a 1)
(def b (+ a 1))
and then change it to just
(def b (+ a 1))
it will still compile! New code is evaluated “on top” of the old one, without unloading the old one first. The definition of a
will persist in the namespace and let b
compile.
It might be really hard to spot these errors during long development sessions.
(require ... :reload-all)
Clojure has :reload
and :reload-all
options for require
. They do track upstream dependencies, but that’s about it.
In our original example, if we do
(require 'a :reload-all)
it will load both b
and c
. This is excessive (b
or c
might not have changed), doesn’t keep track of downstream dependencies (if we reload b
, it will not trigger a
, only c
) and it also “evals on top”, same as with buffer eval.
tools.namespace is a tool originally written by Stuart Sierra to work around the same problems. It’s a fantastic tool and the main inspiration for clj-reload
. I’ve been using it for years and loving it, until I realized I wanted more.
So the main proposition of both tools.namespace
and clj-reload
is the same: they will track file modification times and reload namespaces in the correct topological order.
This is how clj-reload
is different:
tools.namespace
reloads every namespace it can find. clj-reload
only reloads the ones that were already loaded. This allows you to have broken/experimental/auxiliary files lie around without breaking your workflow TNS-65tools.namespace
always reloads everything. In clj-reload
, even the very first reload only reloads files that were actually changed TNS-62clj-reload
supports namespaces split across multiple files (like core_deftype.clj
, core_defprint.clj
in Clojure) TNS-64clj-reload
can see dependencies in top-level standalone require
and use
forms TNS-64clj-reload
supports load and unload hooks per namespace TNS-63clj-reload
can specify exclusions during configuration, without polluting the source code of those namespaces.clj-reload
can keep individual vars around and restore previous values after reload. E.g. defonce
doesn’t really work with tools.namespace
, but it does with clj-reload
.clj-reload
has 2× smaller codebase and 0 runtime dependencies.clj-reload
doesn’t support ClojureScript. Patches welcome.Clj-reload grew from my personal needs on Humble UI project. But I hope other people will find it useful, too.
Let me know what works for you and what doesn’t! I’ll try to at least be on par with tools.namespace
.
And of course, here’s the link:
]]>It’s square, it has a checkmark inside, and its distinguishing feature is that you can select any number of them at the same time:
Different operating systems rendered them differently during their evolution:
As you can see, even the checkmark wasn’t always there. But one thing remained constant: checkboxes were square.
Why square? Because that’s how you can tell them from radio buttons:
Their distinguishing feature is a single choice. If you select one, everything else is de-selected.
I’m not sure when the distinction between square/round was introduced, but it seems to already exist in the 90-s:
(Guess where [x]
is used now? In Markdown! What a comeback, huh?)
And since then, every major operating system followed this tradition. From Windows 3.11:
through Windows 95:
to Windows 11:
from Mac OS 4:
till macOS Sonoma:
There was a brief confusion up until 1986 when Apple used rounded rectangles instead of circles:
but it was quickly resolved.
The point is, every major OS vendor has been adhering to the convention that checkboxes are square and radio buttons are round.
Then the Web came. And when I say Web, I mean CSS. And when I say CSS, I mean Flash and then JavaScript.
You see, people on the Web think conventions are boring. That regular controls need to be reinvented and redesigned. They don’t believe there are any norms.
That’s why it’s common to see radio buttons containing checkmarks:
Or square radio buttons:
Following the Web’s example, native apps introduced us to round checkboxes:
Sometimes people just don’t make distinctions anymore. For example, here the first group is single-choice, while the second one is multiple-choice:
Or here, one of those polls is single-answer, another is multiple-answer:
How are people supposed to know?
But despite all this chaos and temptation, operating system vendors knew better. To this day, they follow THE convention: checkboxes are square, radio buttons are round.
Maybe it was part of their internal training. Maybe they had experienced art directors. Maybe it was just luck. I don’t know — it doesn’t really matter — but — somehow — they managed to stick to the convention.
Until this day.
Apple is the first major operating system vendor who had abandoned a four-decades-long tradition. Their new visionOS — for the first time in the history of Apple — will have round checkboxes.
How should we even call these? Radio checks? Check buttons?
Anyway, with Apple’s betrayal, I think it’s fair to say there’s no hope for this tradition to continue.
I therefore officially announce 2024 to be the year when the square checkbox has finally died.
Kids these days will use a toggle anyway.
Resources used to prepare this post:
]]>Что такое Open Source, как тут программировать и получать опыт, как зарабатывать и стоит ли вкатываться
]]>