Want to write web apps in Roc? Look no further! Joy is a web platform (think framework) for full-stack web applications. It's Elm/TEA-style and fits perfectly with Roc. Joy compiles to WASM and has a JS-shim which updates the DOM. It's a pragmatic fit for most web applications, with attention to performance and ergonomics.
This release contains the front-end side of Joy. You can pair it with any back-end, and pass in state to your front-end via flags. I'll release one or more back-end platforms which are tailored to work with the front-end, but right now it's 6:42 AM and time to sleep! :sweat_smile:
Here's a full example app which you can clone and modify to get started: Joy TodoMVC
If this is your first time writing web apps in Roc you may want this: https://www.youtube.com/watch?v=GnEmD17kYsE
Have fun and enjoy!
Niclas Ahden said:
Want to write web apps in Roc? Look no further! Joy is a web platform (think framework) for full-stack web applications. It's Elm/TEA-style and fits perfectly with Roc. Joy compiles to WASM and has a JS-shim which updates the DOM. It's a pragmatic fit for most web applications, with attention to performance and ergonomics.
This release contains the front-end side of Joy. You can pair it with any back-end, and pass in state to your front-end via flags. I'll release one or more back-end platforms which are tailored to work with the front-end, but right now it's 6:42 AM and time to sleep! :sweat_smile:
Here's a full example app which you can clone and modify to get started: Joy TodoMVC
If this is your first time writing web apps in Roc you may want this: https://www.youtube.com/watch?v=GnEmD17kYsE
Have fun and enjoy!
Jazz fusion does give me joy :smile:. Great work!
Knower! :smiley:
Hey @Niclas Ahden , I'm playing with Joy right now, and I got the hello.roc and counter.roc examples working, but I ran into a few issues along the way:
watchexec installed, so when running./watch.roc examples/hello.roc, I only got the message: => Serving 'examples/hello.roc' at http://localhost:8000 then the program would stop with Exit(1) but without any error message.watchexec, I ran the same command and got this output:% ./watch.roc examples/hello.roc
=> Serving 'examples/hello.roc' at http://localhost:8000
[EVENT 0] Event
[EVENT 0] Event
[Running: ./build.roc examples/hello.roc]
[Command killed by ForceStop]
The URL gave nothing, so I ended up interrupting the program and starting again. During this third run, it displayed this:
% ./watch.roc examples/counter.roc
=> Serving 'examples/counter.roc' at http://localhost:8000
[EVENT 0] Event
[EVENT 0] Event
[Running: ./build.roc examples/counter.roc]
roc 1.0s
✓ Resolving Dependencies 4ms
<snipped>
✓ ARC 53ms
[Command was successful]
This time I got the "Hello, Roc!" message in the browser. :tada:
Feeling joyful, I interrupted the program, and ran ./watch.roc examples/counter.roc. It seemed to work, but oddly it still displayed "Hello, Roc!". I refreshed the page, I pressed Cmd-Shift-R, I restarted the program, but it always displayed "Hello, Roc!". Suspecting some caching issue, I changed the port by running JOY_WATCH_PORT=8024 ./watch.roc examples/counter.roc and I finally I saw the counter. :plus: 0 :minus:
Note: the counter resets when I refresh the page, I suppose that's normal, but perhaps worth mentionning?
Hope this helps! I'll try the other examples now.
Trying the keyboard.roc example, I'm running into an issue: the page loads but as soon as I type a key on my keyboard, an error appears in the console: function signature mismatch, and the counters remain at 0:
![]()
For logging.roc, I get a weird message when running ./watch.roc examples/logging.roc:
![]()
Seeing this message, I really didn't expect it to work, but I tried anyway, and to my surprise it works fine:
![]()
Just tried the modal.roc example: worked like a charm. No issues at all (other than the caching problem I mentioned earlier, which forces me to use a different port for each example).
For the pointer.roc example, I get a signature mismatch error as soon as I hover over the "Draw here" region:
![]()
For the time.roc example, I get a signature mismatch error after about 1s when loading the page:
![]()
Side-note: the counter should start at 100! :grinning_face_with_smiling_eyes:
Oh if I quickly click on "Calm down" after reload, then no error appears, the button turns to "Get excited!", and clicking the "Remember this moment" button displays "Level 0, reached at 0". But when I click on "Get excited!", I get the mismatch error after about one second and then nothing works anymore.
Well, this was fun. Let me know if you'd like me to run more tests.
Listening to this while coding in Roc brings me Joy.
Thank you @Aurélien Geron for trying it out and all the great feedback!
I'll fix the port errors and refine the README. You wouldn't have run into those issues at all if the instructions were better!
Sadly those app errors are due to incompatibility with newer Roc compiler versions, and I can't move forward due to some compiler bugs right now (https://github.com/roc-lang/roc/issues/10731 and https://github.com/roc-lang/roc/issues/10733). I'm constantly testing new compiler commits and fixing compatibility and reporting bugs, so we just need more time to reach stability :) Bugs are fixed at an alarming rate so it shouldn't be much longer!
To get around this, please build Roc from source from the latest "known-to-work" commit: scratch that, I forgot that I had to patch the compiler, so 94cbed386c51a8739ced3be76e7ab7b84dd22852 (the latest one is always in flake.nix in the repo root).94cbed386c51a8739ced3be76e7ab7b84dd22852 isn't known-to-work. I'll keep an eye on the bugs above and update it when they're fixed!
If you're able to nix develop then you'll get _exactly_ what you need! However, you shouldn't have to adopt nix in order to use Joy, so the long-term goal is of course stability as everything matures :)
And finally, love that Angine de Poitrine song :) It's going in my work playlist!
Fun fact: Angine de Poitrine is named after a specific type of chest pain when your heart doesn't receive enough oxygen, which is what they figured the music sounds like :laughing:
Hahahah, that makes so much sense :joy:
@Niclas Ahden Once you figure out the backend side of Joy, will it support end-to-end type safety?
@Asguho Yes (as long as we mean the same thing! :sweat_smile:). Here's an example:
Joy apps are structured in three dirs:
client
shared
server
Since Joy is built like Elm, you need a model and view to render something. We put those in shared so that they're accessible to both the front-end and back-end. When we render a page we do:
model.view using the model.model as JSON and hands it to the front-end as flags in the app shell.model (using the same shared definition) and renders the same view (using the same shared definition). Note: we do hydration, so it's not a full "render and replace".This gives you end-to-end type safety for rendering.
When it comes to type safety for the communication between them, I've been using two approaches:
A) The same kind of thing as the above: shared definitions of payloads for requests.
or
B) Abstracting it to feel like "message passing", so requests are encoded as "messages" (think client sending GiveUserCandy({ user_id: 42, candy: "Rollo"}) to the server) and the definition of these messages are shared. These can be sent as regular HTTP requests, SSE, and/or WSS (although I'm personally only using normal requests and SSE, no WSS).
Ah yeah, that makes sense. I think I was mostly thinking about A before.
B is quite a bit more interesting though, especially if the transport is basically just an implementation detail. How do you handle responses/errors for something like GiveUserCandy? Is there a matching response type, or does the server send another message back?
For B I've experimented with both ways you mentioned, but haven't decided on a single path forward. Sadly I couldn't push this all the way with the old compiler. It's going to be exciting to start using the new compiler more and having some spare time to build this out :)
New release: 0.33.0 :tada: https://github.com/niclas-ahden/joy/releases/tag/0.33.0
Thank you @Anton and @Richard Feldman for squashing all the Roc bugs I'm reporting! Roc is now the best it has ever been for wasm and the web!
Joy now seems to sit between Elm and Leptos in performance and bundle size. Sweet!
These numbers are from a local run of js-framework-benchmark and give you some idea of our relative performance:
| Joy (keyed) | Joy (non-keyed) | Elm (keyed) | Elm (non-keyed) | Leptos | SolidJS | React | vanilla JS | |
|---|---|---|---|---|---|---|---|---|
| Slowdown vs best | 1.42Ă— | 1.28Ă— | 1.52Ă— | 1.39Ă— | 1.39Ă— | 1.24Ă— | 1.95Ă— | 1.07Ă— |
| Memory (MB) | 3.6 | 3.6 | 1.3 | 1.3 | 3.5 | 1.0 | 2.2 | 0.8 |
| Bundle, compressed (KB) | 27.5 | 27.3 | 7.9 | 8.2 | 48.8 | 4.5 | 51.4 | 2.4 |
Bundle is the brotli-compressed transfer of everything the page loads, so for Joy that covers the WASM, the JS runtime, and the HTML. The WASM is the bulk of it at 22.1 KB, and the minified JS runtime is 5.0 KB.
Don't take these numbers as gospel, since they're from a local run, not an official one. We can have higher trust in the numbers once Joy is included in the official runs :)
For now, we can say: if you're happy with Elm's or Leptos's performance, you'll probably be happy with Joy's too!
The optimizations which led to this are mainly that we now do more work in wasm and pass the JS boundary less than we did before. wasm is faster than JS on CPU-bound work and it makes quite a difference!
Each release now recommends a compatible Roc version so that you know which version to use. We keep the recommendation fresh by running our tests against the latest Roc nightly every day and updating the recommendation if tests pass. That way you can use the latest Joy release, with the latest compatible Roc nightly, and be as bleeding edge as you'd like!
Note: we only update the recommendation on the latest Joy release.
We now have an extensive test-suite:
roc-playwright.--opt={speed,size,dev}).Hopefully this'll help us keep Joy working through all Roc releases and new features!
Clone the Joy TodoMVC example, and you'll have a complete front-end app setup in no time!
Very impressive! It brings me joy :)
Thanks Anton, it's a been a lot of work to get to this point :)
wow, this is amazing! :heart_eyes:
I didn't realize how competitive we are on perf
this has to be the most battle-tested and mature Roc platform now, yeah?
I've gotten excellent perf numbers for pretty much anything I've looked into with Roc. It's generally just a matter of getting the accidental quadratics out which tends to be in-place optimization or not.
Richard Feldman said:
this has to be the most battle-tested and mature Roc platform now, yeah?
I would assume so? Three apps are using it in production right now and it's working nicely (internally at customers, not public-facing). Next up is adding hydration so that I can migrate full-stack apps. That'll provide a lot of stress testing for sure!
It'd be fun to get it running in a public-facing app, but clients are usually more stringent about those for some reason ;D
Niclas Ahden said:
These numbers are from a local run of js-framework-benchmark and give you some idea of our relative performance:
@Niclas Ahden what would we need to include roc-signals in this comparison? I'd love to see the comparison with the other frameworks.
You have inspired me to do some benchmarking on roc-signals and see how close/far that is from these numbers :grinning_face_with_smiling_eyes:
Go for it Luke! I just implemented Joy’s entries for the js-framework-benchmark and ran the suite locally on a quiet machine a number of times to ensure I’m getting stable numbers :ok:
I'll give it a go -- I've made the benchmark fixture... and then you know how these go, I've been a little distracted improving things :sweat_smile:
That's what we love about you! Roc wouldn't be this great at wasm if it weren't for you! :D
1.3x slowdown vs vanilla js is amazing, unnoticeable latency compared to the best in class !
The biggest leverage now is actually the bundle size. Roc will compile the whole app into a big wasm bundle. That is bad for Time to Interactive (TTI).
Javascript frameworks can do bundle splitting, but for Rust/Roc it is not a language feature.
Fortunately Leptos has pulled off WASM binary splitting and lazy loading
https://www.youtube.com/watch?v=w5fhcoxQnII
https://book.leptos.dev/deployment/binary_size.html
It is using macros which insert special annotations that end up in the wasm binary so the splitter can do its job. Not sure that is doable in current roc unfortunately, but probably worth some exploration.
unrelated question, but how do you handle hierarchical messaging in Joy ?
suppose I have to pages [Editor, Analytics], each with N possible messages. I don't want the top level update() to handle all the possible messages of the app.
Page : [Editor, Analytics]
Msg : [
EditorSave,
EditorUndo,
AnalyticsRefresh,
AnalyticsExport,
]
update = |model, msg|
match msg {
# Editor messages
EditorSave => ...
EditorUndo => ...
...
# Analytics messages
AnalyticsRefresh => ...
AnalyticsExport => ...
...
}
view = |model|
match model.page {
Editor =>
div({}, [
button({ on_click: EditorSave }, [text("Save")]),
button({ on_click: EditorUndo }, [text("Undo")]),
])
Analytics =>
div({}, [
button({ on_click: AnalyticsRefresh }, [text("Refresh")]),
button({ on_click: AnalyticsExport }, [text("Export")]),
])
}
i would like to be able to write Analytics.roc with its own init, update, render.
something like this would scale better
EditorMsg : [Save, Undo]
AnalyticsMsg : [Refresh, Export]
Msg : [
Editor(EditorMsg),
Analytics(AnalyticsMsg),
]
update = |model, msg|
match msg {
Editor(msg) => { ..model, editor: Editor.update(model.editor, msg)}
Analytics(msg) => { ..model, analytics: Analytics.update(model.analytics, msg)}
}
do you have an example to point to ?
@Romain Lepert Yes, that's indeed the way! You've also got Html.map, Effect.map, and Sub.map:
update = |model, msg|
match msg {
Left(m) => ({ ..model, left: counter_update(model.left, m) }, [])
Right(m) => ({ ..model, right: counter_update(model.right, m) }, [])
}
render = |model|
div([], [
Html.map(counter_view(model.left), |m| Left(m)),
Html.map(counter_view(model.right), |m| Right(m)),
])
init = |_| ({ left: 0, right: 0 }, [Effect.map(counter_boot({}), |m| Right(m))])
subscriptions = |_model| [Sub.map(counter_ticks({}), |m| Left(m))]
I'll make sure to add both a SPA and a non-SPA example of nesting :ok:
Right now I'm working on hydration, so perhaps a basic full-stack example will be a good place to do some nesting :)
I made something (for myself) with joy using Joy! It's a simple case, but it was really easy, and I ran into no issues
Awesome! Thanks for trying it out, and do let me know if you use it more and run into any issues. I'm keen to know what problems people have :) The "new user experience" is always difficult to gauge
Last updated: Sep 03 2026 at 15:16 UTC