Runtime error due to browser rate-limiting

I have encountered a runtime error in my Elm app and am looking for thoughts on the root of the problem. I’ll describe what I tried to do, how it failed, and how I worked around it.

Desired App Feature

In my photo album app, I wanted the user to be able to scroll down a large page of thumbnails, click on a picture to see it full size, then click back and have the scroll position restored.

I achieved this by listening for onScroll events, putting the current scroll position in a query param in the URL via Browser.Navigation.replaceUrl, and then on the “back” navigation I parse that query parameter and scroll to that position on the thumbnail page.

Exception Observed in Production

This worked great on Chrome & Firefox, so I released it into production. However, I soon began getting reports from users of Safari on iPad that the app would just stop responding to all taps sometimes.

When I finally got ahold of a mac I could debug on, I opened up the Safari console and found this:

[Error] SecurityError: Attempt to use history.replaceState() more than 100 times per 30.000000 seconds
replaceState (elbum.js:4061)
(anonymous function) (elbum.js:4061)
_Scheduler_step (elbum.js:959)
_Scheduler_enqueue (elbum.js:933)
_Scheduler_rawSend (elbum.js:879)
_Platform_dispatchEffects (elbum.js:2057)
sendToApp (elbum.js:1886)
callback (elbum.js:2956)

:frowning:

Low-Level Browser Cause

Safari rate-limits calls to replaceState, and since I was updating my query param in an onScroll handler, it could end up getting called quite rapidly as the user was scrolling the thumbnail page.

As far as I could tell, this basically killed the Elm event loop, and no more mouse clicks etc. were processed in the app.

There is a fair bit of discussion in webkit bug 156115 about whether this is in fact a standards-compliant thing for Safari to do. The participants in that discussion ended up agreeing that it is. In particular quoting this bit of the spec:

User agents may impose resource limitations on scripts, for example CPU quotas, memory limits, total execution time limits, or bandwidth limitations. When a script exceeds a limit, the user agent may either throw a " QuotaExceededError " DOMException , abort the script without an exception, prompt the user, or throttle script execution.

App-Level Work-Around

The discussion in the webkit bug points out, among other things, that it probably would’ve been a better approach for my app to intercept the thumbnails → fullsize click event, grab the scroll position once, and call replaceState once. I see their point … but it’s also the case that I found it made my code simpler to continuously track the scroll position.

So, my work-around was to use jinjor/elm-debounce to limit the rate at which I propagated the onScroll updates out to my model & thus the URL. This has fixed the problem reliably as far as I can tell.

Whose Problem Is This, Really?

  1. Safari’s violating the spec?
  2. Elm should recover more gracefully from a QuotaExceededError?
  3. A warning should be added to Browser.Navigation.pushUrl and .replaceUrl warning app developers not to call them too frequently?
  4. Something else I’m not thinking of?

If it helps, I can probably put together a SSCCE that will demonstrate the problem, and/or file an issue against elm/browser.

4 Likes

If I am reading this correctly, you got a run time error in production :fearful::fearful::fearful:

As an aside, this isn’t solving the particular problem of course, but I’ve been writing a photo gallery too and solved the same scrolling issue in a manner that at least doesn’t kill browsers.

  • an onClick event that shows the full screen image runs a task which calls Browser.Dom.getViewport
  • save the viewport.viewport.y position to viewportOffset in the model
  • when the close image event occurs, fire off a Browser.Dom.setViewport 0 model.viewportOffset event.

That’s it!

Take a look at these two messages for the full story.

Yes, I did. :frowning:

To be fair, “production” is just my personal photo website, not some big corporate app with millions of users or a strict SLA or anything … but still. It does mean I can no longer make the “no runtime errors in production” claim at work with a straight face. :-/

Cool – is there anywhere I can see it live?

I don’t think it’s really fair to blame Elm here. You are using the Navigation module in an extremely unintended way! That is you are basically using it as a global variable to store the scroll state. If you really want to store it then store it in your Model. Personally I would agree with the advice that you got that you should only store the scroll state when you actually need it (i.e. when the thumbnail is clicked). Could you share a code snippet to explain why you though that made the code more complex?

1 Like

I’m trying to withhold judgment on who/what to blame … :slight_smile:

My understanding is that when the user presses the Back button, my model is essentially thrown away and the app loads “from scratch” … so if I want to restore the scroll position, don’t I have to store in in the URL?

That’s at least the way it works if someone bookmarks or shares a “deep link”, I’m fairly certain. Maybe restoring the scroll position isn’t a very compelling thing to do in those cases, but certainly some state must be stored in the URL to enable these scenarios.

(I do also store the scroll position in my model, so that pressing the Esc key works like the back button, FWIW.)

Basically I can’t have a direct transition on ViewFullImage msg, from ViewingThumbs model to ViewingFull model. I have to have an intermediate GettingScroll model, and a new GotScroll message.

According to the latest Elm Guide on Navigation clicking on the BACK button in the browser generates a UrlChanged message (or whatever you have called it in your code). I’ve just written some toy code to check this and I can confirm that the app isn’t loaded from scratch when this happens, all your model state is persisted. So provided you handle the message you should still have access to your latest model state. Thus no need to store it in the URL.

Well you still need some kind of GotScroll message regardless when you subscribe to the scroll. If you really wanted to avoid the intermediate model state you could simply store the last clicked thumbnail and then use the GotScroll message to trigger the transition (possibly naming it more explicit to remind you that it’s triggering the transition). For example …

type Model
    = ViewingThumbs { clicked : Maybe Image, key: Browser.Navigation.Key }
    | ViewingFull { image : Image, previousScrollPos : Float, key: Browser.Navigation.Key }

type Msg
    = ClickedImage Image
    | TransitionToFull Browser.Dom.Viewport
    | UrlChanged Url.Url

update : Msg -> Model -> (Model, Cmd.Msg)
update msg model =
    case msg of
        ClickedImage image ->
            case model of
                ViewingThumbs state ->
                    ( ViewingThumbs { state | clicked = Just image }
                    , Task.perform TransitionToFull Browser.Dom.getViewport
                    )

                _ ->
                    ( model, Cmd.none )

        TransitionToFull viewport ->
            case model of
                ViewingThumbs {clicked, key} ->
                    case clicked of
                        Just image ->
                                ( ViewingFull { image = image, previousScrollPos = viewport.y, key = key }
                                , Browser.Navigation.replaceUrl key "FULLSCREENURL"
                                )

                        Nothing ->
                            ( model, Cmd.none )

        UrlChanged url ->
            -- handle BACK button here

(I haven’t compiled the above so it may contain some typos!)

Okay, thanks Jess, I have some alternatives to consider!

Not quite yet. It’s not as polished as I’d like at the moment. I’ll write up a post here about it soon though.

And I see from the recent comments, your problem is a little more specific than my answer. Interaction with the browser history isn’t something I’ve looked into yet on my side. If I find a better solution to that I’ll let you know.

Okay, thanks for the feedback. I’ve opened a PR to add a warning to the docs, as that seems like the simplest thing until a longer-term solution can be agreed upon.

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