External assets drag page load speed in Elm SPA

Hi, I’m building a SPA in elm with many JS and CSS dependencies for things like LaTeX in markdown, highlight.js, vega-lite, etc. The app consists of a home page that’s linked to several tutorial pages. I followed the general practice of loading all JS files at once in index.html. All files served from CDNs are minimized and the main.js generated from Elm is minimized and compressed. However, the load speed is horribly slow because a ton of unnecessary assets are loaded upfront in the home page. As for tutorial pages that do require the assets, the load time is even worse then the home page as reaching the first contentful paint requires 6.2s on mobile vs 2.6s for the home page on mobile. I attempted to dynamically load JS depending on the URL but can’t figure out how to do it. I believe this can be a common issue for people using many external JS and CSS dependencies, so is there a solution to better manage them? Thanks.

Link to SPA:
Link to project repo:
current dev version: https://github.com/AlienKevin/AIWaffle-website
production version: https://github.com/AlienKevin/AIWaffle-website/tree/d1e27b3c5ff43d3bfb533b5c01790e96cfd22ab1

A server is serving your index.html. Have the server serve different files on different routes. The Elm code can remain the same but the libraries that you are loading can be different for each route.

For example, you could have src/index.html and src/tutorial/index.html or src/tutorial.html.

Also, looking at your deployment there are some opportunities for optimization. waffle.png is 257kb. Try using some svg for the pattern. Also, highlight.pack.min.js is very large because it contains a lot of plugins. If you don’t use all the languages, consider creating an optimized version with only the languages you intend on using.

Later edit: I played a little bit with the PNG and you can reduce it to the following 1kb SVG:

<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><symbol id="a"><path d="M2.43591 5.14011c-.85705 2.83407-3.41915 57.80982-1.2298 60.173C3.39544 67.67627-.12843 68.8941 38.1953 69c38.32373.10589 26.1416-.98283 27.90643-2.24816 3.00396-2.15376 3.92957-1.46695 3.8954-26.26614-.02812-19.9546-.84297-31.63482-2.44655-35.03853C66.158 2.48833 59.45333 1.22645 40.41638.3389c-9.8395-.45788-11.1748-.4557-20.66264.03038-9.48783.4861-16.46078 1.93676-17.31783 4.77083z" fill="#EBAB2C"/><path d="M6 2.65813C6 3.59555 34.07178 7.8939 49.02246 17.8627 66.77375 29.6989 67.7 57.27656 67.811 57.38598c2.762 2.72254 2.094-47.44736-.706-53.0748C65.648 1.38262 41.195-.76624 21 .25989c0 0-15 1.46083-15 2.39824z" fill="#DB8323"/></symbol><symbol id="c"><use xlink:href="#a"/><use xlink:href="#a" x="80"/><use xlink:href="#a" x="160"/><use xlink:href="#a" x="240"/><use xlink:href="#a" x="320"/></symbol><mask id="b" fill="#fff"><circle cx="200" cy="200" r="186"/></mask></defs><g fill="none" fill-rule="evenodd"><circle fill="#F3CB4C" cx="200" cy="200" r="200"/><g mask="url(#b)"><use xlink:href="#c"/><use xlink:href="#c" y="80"/><use xlink:href="#c" y="160"/><use xlink:href="#c" y="240"/><use xlink:href="#c" y="320"/></g></g></svg>

Those suggestions are awesome! Just curious, how did you convert the PNG to SVG?

I used https://www.pngtosvg.com/ for a first conversion, extracted the cell into a symbol and then just manually created the row by duplicating a symbol usage with different x values, duplicated the row with different y values. Added a circle as base and another circle as a mask to the matrix of cells.

That’s such a clever approach. No encryption method can beat that 1.25kb file size.

I implemented all your other recommendations but have trouble splitting the HTML. How could the server detect the routing or url change? when I clicked on a link in the browser, Elm never sends a routing request to the server. So I changed the routing function to

route : Url.Url -> Model -> (Model, Cmd Msg)
route url model =
    parser =
        [ Parser.map
          ( stepHome model (Home.init ())
          (Parser.s "home")
        , Parser.map
          (\tutorialName ->
          - let
          -   name =
          -     Maybe.withDefault tutorialName <| Url.percentDecode tutorialName
          - in
          -  stepTutorial model (Tutorial.init name)
          + ( model
          + , Nav.load <| Url.toString url
          + )
          (Parser.s "tutorial" </> tutorialName_)
        , Parser.map
            (stepHome model (Home.init ()))
  case Parser.parse parser url of
    Just answer ->

    Nothing ->
      ( { model | page = NotFound }
      , Cmd.none

which seems to enter an infinite loop when I tried to navigate to tutorial pages.

Good point. You need to check in the internal link handler if you are coming from a different region of the site and force a full reload.

Define a new custom type type Region = Light | Heavy and a pageToRegion function and check if the Page you get from the url has the same region with the page you have in the model. If they are the same, use Nav.pushUrl model.key (Url.toString url) if not Nav.load (Url.toString url).

It might be useful to refactor the parser to produce pages and pattern match on the produced page in route. This way you will be able to reuse it in the LinkClicked.

I have refactored your Main.elm to show this. Please note that I haven’t tested the implementation, only made sure that there are no compiler errors.

module Main exposing (main)

import Browser
import Browser.Navigation as Nav
import Html
import Page.Home as Home
import Page.NotFound as NotFound
import Page.Tutorial as Tutorial
import Url
import Url.Parser as Parser exposing ((</>), Parser)


main : Program () Model Msg
main =
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        , onUrlChange = UrlChanged
        , onUrlRequest = LinkClicked


type alias Model =
    { key : Nav.Key
    , page : Page

type Page
    = NotFound
    | Home Home.Model
    | Tutorial Tutorial.Model

init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
    route url { key = key, page = NotFound }


type Msg
    = LinkClicked Browser.UrlRequest
    | UrlChanged Url.Url
    | NotFoundMsg NotFound.Msg
    | HomeMsg Home.Msg
    | TutorialMsg Tutorial.Msg

update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
    case message of
        LinkClicked urlRequest ->
            case urlRequest of
                Browser.Internal url ->
                    route url model |> reloadIfNeeded url model

                Browser.External href ->
                    ( model, Nav.load href )

        UrlChanged url ->
            route url model

        HomeMsg msg ->
            case model.page of
                Home home ->
                    stepHome model (Home.update msg home)

                _ ->
                    ( model, Cmd.none )

        TutorialMsg msg ->
            case model.page of
                Tutorial tutorial ->
                    stepTutorial model (Tutorial.update msg tutorial)

                _ ->
                    ( model, Cmd.none )

        NotFoundMsg _ ->
            ( model, Cmd.none )


type Route
    = HomeRoute
    | TutorialRoute String
    | UnknownRoute

type Region
    = Light
    | Heavy

pageToRegion : Page -> Region
pageToRegion page =
    case page of
        Tutorial _ ->

        _ ->

urlToRoute : Url.Url -> Route
urlToRoute url =
        parser =
                [ Parser.map HomeRoute Parser.top
                , Parser.map HomeRoute (Parser.s "home")
                , Parser.map TutorialRoute (Parser.s "tutorial" </> tutorialName_)
    Parser.parse parser url
        |> Maybe.withDefault UnknownRoute

route : Url.Url -> Model -> ( Model, Cmd Msg )
route url model =
    case urlToRoute url of
        HomeRoute ->
            stepHome model (Home.init ())

        TutorialRoute content ->
            stepTutorial model (Tutorial.init content)

        UnknownRoute ->
            ( { model | page = NotFound }, Cmd.none )

reloadIfNeeded : Url.Url -> Model -> ( Model, Cmd msg ) -> ( Model, Cmd msg )
reloadIfNeeded url oldModel ( newModel, cmd ) =
    if pageToRegion newModel.page == pageToRegion oldModel.page then
        ( newModel, Cmd.batch [ cmd, Nav.pushUrl newModel.key (Url.toString url) ] )

        -- Ignore the cmd because the page will be reloaded
        ( newModel, Nav.load (Url.toString url) )

tutorialName_ : Parser (String -> a) a
tutorialName_ =
    Parser.custom "TUTORIAL" Just

stepHome : Model -> ( Home.Model, Cmd Home.Msg ) -> ( Model, Cmd Msg )
stepHome model ( home, cmds ) =
    ( { model | page = Home home }
    , Cmd.map HomeMsg cmds

stepTutorial : Model -> ( Tutorial.Model, Cmd Tutorial.Msg ) -> ( Model, Cmd Msg )
stepTutorial model ( tutorial, cmds ) =
    ( { model | page = Tutorial tutorial }
    , Cmd.map TutorialMsg cmds


subscriptions : Model -> Sub Msg
subscriptions _ =


view : Model -> Browser.Document Msg
view model =
    case model.page of
        NotFound ->
            { title = "AIWaffle"
            , body =
                [ Html.map NotFoundMsg <| NotFound.view {}

        Home home ->
            { title = "AIWaffle"
            , body =
                [ Html.map HomeMsg <| Home.view home

        Tutorial tutorial ->
            { title = Tutorial.getContentName tutorial.contentIndex
            , body =
                [ Html.map TutorialMsg <| Tutorial.view tutorial
1 Like

Thanks a lot for the detailed solution. It largely works but there are some weird problems that surfaced so I decided to revert back to the single index.html model. All are fine except some html contents from the tutorial page persisted when I navigate back to the home page. I have no idea why this happens.

Using Html.Keyed on the div that holds the artifact might help.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.