Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[gatsby-image] Placeholder flicker/transition when image is cached by browser #25942

Closed
polarathene opened this issue Jul 22, 2020 · 9 comments
Closed
Labels
topic: media Related to gatsby-plugin-image, or general image/media processing topics type: bug An issue or pull request relating to a bug in Gatsby

Comments

@polarathene
Copy link
Contributor

polarathene commented Jul 22, 2020

Description

Using a placeholder such as base64 is nice to have for first retrieval of assets. However when they are cached this results in:

  • Brief flicker during hydration due to inlined placeholder.
  • When navigating to a page with an image if no hit within the internal gatsby-image cache:
    • Brief flicker for lazy loading via Intersection Observer.
    • Transition triggered for native lazy loading.

It's a minor UX issue, but it would be nice to smooth these out.

Examples

base64_flash_bug

https://i.imgur.com/rRq7BGW.mp4

Related

timhagn/gatsby-background-image#92
#18858
#12254 (comment)

#12836

when the actual images load fast, the placeholder is more irritating then helpful.

#14158

imgCached will be false in situations that IO isn't used for lazy-load, thus shouldFadeIn won't be disabled, which afaik means we'll probably run into #12254 again?


Discussion

I can put together a PR resolving these as best as possible if desirable.


Hydration flicker

Can be resolved by adding keyframe CSS in a <style> element. This allows for having the placeholder opacity at 0, and toggling it to 1 via animation rule.

Drawback is it adds to page weight (although compression may optimize that away), the more images there are each adding that same element and CSS. Unless we place this in the HTML base within the <head> instead, then it can be toggled via className.

A delay would need to be around 200-300ms minimum(based on 200ms until gatsby-image code executed from performance.now() and 10-100ms for a cached response to update state to render), so a flicker would still be visible, it would just be similar to an externally linked <img> flashing in over whatever the background was, instead of a potentially large upscaled 20px base64 pixelated looking image before the cached image renders. The backgroundColor placeholder, especially with an appropriate colour can reduce that "flash" / flicker appearance.

Presently CSS can be used to add a blur filter and reduce the low quality pixelated flicker impact, but still may not be desirable. Likewise, in this case a user could provide the keyframe CSS externally, so long as they have a reliable className to target(not presently supported).

Internally, this does not stop the fade transition. There are two cases to handle for this:

  • imgCached within handleRef() can be used, this leverages the image currentSrc to know if the browser has the image in cache without always needing to make a network request. When it is a falsy value, the browser will always log a network request, pulling from disk-cache or querying the server if the asset has changed delaying the assignment of currentSrc, thus even if the asset is cached locally it may not be set reliably when handleRef() fires.
  • As a fallback to imgCached, using performance.now() only once within gatsby-image module, all instances can reference when it was first available since the page loaded. A value of <500ms would be a good indication that the page has been previously loaded before, the browser should reliably have access to the cache if the image to load is consistent, probably not reliable if the viewport dimensions have changed that a different image source would be selected.

The keyframe CSS can be used after hydration as well and removed once imgLoaded fires. Doing so would momentarily defer the placeholder visibility, if a cached image is not quickly retrievable. On throttled Fast 3G if the server needs to be queried if content has changed due to Cache-Control header expiring, the latency imposes ~500ms(Chrome to local gatsby serve or Caddy server) and 2sec for Slow 3G to return a 304(~128 bytes response). The higher the latency, the less likely the user would witness this flickering issue, so this approach is still valid.

TL;DR

The performance.now() fallback is probably too much of an assumption to include, users would need to wait until v3 with gatsby-image modularized into a composable component to implement this approach if acceptable for them.

imgCached is still worthwhile even if it fails sometimes (would not break anything).

I am not expecting the animation CSS with keyframe in <script> being part of the gatsby-image component by default would be welcomed, especially since it should be possible for a user to configure for, like they would an improved blur-up via CSS filter. The className to target however would be required.


Intersection Observer

Without the CSS keyframes approach, I don't see this feature being possible to skip the initial placeholder rendered frame with page transitions. That frame in my measurements was lasting ~40ms regardless of network throttled speed, when the resource was cached.

Hiding the placeholder via the CSS animation opacity toggle works, but effectively transfers the flicker issue to those loading the resource over network, however, this would only be visible AFAIK during initial page loads where hydration is involved, making it far less likely to be encountered.

[blank] -> placeholder while retrieving JS -> hydration -> [blank] -> placeholder while retrieving image -> image


Native Lazy Loading

Unlike Intersection Observer instances, these have isVisible as true and only hide visibility of the image element until it's loaded. Using imgCached here works well too.

@polarathene polarathene added the type: bug An issue or pull request relating to a bug in Gatsby label Jul 22, 2020
@gatsbot gatsbot bot added the status: triage needed Issue or pull request that need to be triaged and assigned to a reviewer label Jul 22, 2020
@polarathene polarathene added topic: media Related to gatsby-plugin-image, or general image/media processing topics type: question or discussion Issue discussing or asking a question about Gatsby and removed status: triage needed Issue or pull request that need to be triaged and assigned to a reviewer labels Jul 22, 2020
@polarathene
Copy link
Contributor Author

polarathene commented Jul 23, 2020

Caching observations regarding imgcurrentSrc and imgCached use

A browser may avoid waiting on a response for <img> if a cached copy can be displayed in the meantime

When max-age expires and the browser(Chrome in this case) makes a request to check if resource has been modified, on Slow 3G throttled network, TTFB is 2 seconds long, despite this, img.currentSrc was valid prior and the image was rendered before the response(TTFB) was returned, where the handleImageLoaded() method is triggered or img.complete===true. The browser chose to serve the local cached copy without waiting.

A browser may cache an image, but does not guarantee img.currentSrc will be set depending on cache layer

When switching quickly between image variants for an art directed image (with my active PR), sometimes the img.currentSrc is empty, while max-age would not have expired. It pulls it from disk-cache has no TTFB delay due to no network request made("200 OK" response, not "304 not modified"), seems to be reliable for memory-cache or when making a network request though. If cache is disabled, img.currentSrc will be empty as expected and the proper image will not transition from placeholder until handleImageLoaded() fires.

img.currentSrc may indicate a locally cached image, but the rendered cached image may be replaced if the response differs

Final test was to wait for max-age to expire and swap the image for a different image large enough to take a while on Slow 3G to retrieve. img.currentSrc returned the URL, and as expected showed the cached resource prior to waiting on an image response. Once the first byte of the response arrived, the new image I had replaced on disk began to load in..

img.currentSrc is a valid solution when the resource data at a given URI is immutable

This does mean it's technically incorrect in this situation.. especially since imgCached being set would remove the placeholder and immediately reveal the img element, showing the progressive image download(either pixelated, slowly improving clarity as more pixels arrive, or the top to bottom scanlines). That would normally be a problem, but since image URIs are meant to be unique, the browser should never run into this situation where it has a different image than the one it's previously cached for that URI.

Therefore, this approach is still deemed valid, but should probably include a developer note for any maintainers to be aware of this behaviour and expectation/assumption being made with resource URIs provided to gatsby-image.

@polarathene
Copy link
Contributor Author

polarathene commented Jul 24, 2020

For isCritical images, those exist in the DOM prior to React, thus img.currentSrc is always set for these, img.complete appears to be reliable instead and has been used in componentDidMount() which worked until my hydration PR changed the call order with handleRef().


For images that are not using isCritical, img.complete also seems valid for when the memory-cache is used, but not when performing a network request(due to TTFB delay, whereas img.currentSrc is valid), or disk-cache which also doesn't work for img.currentSrc either. Network throttle doesn't appear to influence that, nor the DOM load event relative to the image resource timing.


Notes about `Cache-Control` response header (not likely of value to most)

Additional observation, if max-age has expired, img.currentSrc can be valid when refreshing the page, but when transitioning from another(referencing the basic starter project with image on 1st page and no image on 2nd page), img.currentSrc is no longer set.. When memory-cache or disk-cache are used, their behaviours are unchanged and still behave as described earlier.

For this observation, the response headers were slightly different. Additional fields were in the response header, Accept-Ranges: bytes, Content-Length: <length>, Content-Type: image/jpeg, Last-Modified: <timestamp>, everything else looks the same though and both are a 304 Not Modified 🤷


Sometimes these are 200-OK responses, and always if visiting the page/url via new tab if the cached resource has become stale, seems soft reload/refresh does something different, where I guess if the URL hasn't changed for the active page, I get the better caching behavior. A hard reload adds to request headers Cache-Control: no-cache and Pragma: no-cache, which can affect soft reloads as until cached resource becomes stale again, those request headers are shown for the resource still, instead of memory-cache stating it's only showing provisional headers. Chrome also offers a Empty cache and hard reload option, which I assume is equivalent to checking "disable cache" in network tab and doing a hard reload, where content will download regardless.

Similar to experiences with the "Performance" profiling tab in dev tools, where I noticed some behavior changing, I had found the browser was using cached response headers with soft reloads that should have been different due to revalidation when stale, perhaps because the resource was a 304, it did not update the response headers and those were cached with the resource.


Noticed that the addition of no-cache request headers in a hard reload only affects "Online"(none) network throttling, which seems to be re-downloading content again, whereas throttled network will utilize memory/disk caches and doesn't add no-cache to the request headers..

Another difference with hard vs soft reload is the image is always returning a 200-OK response, including once stale and validating for an update, additionally, I have tested with a replacement image of different dimensions in the static file location, in soft refreshes where validation request is sent, the image resizes to fit the width/height of the image wrapper that has the original image placeholder base64, then once the validation response is returned, it will show the image with it's proper proportions cropped with object-fit, memory/disk caches skip the distorted step and hard reload doesn't have the distorted render either during revalidation.

The brief distorted image did happen to render on hard reload "Slow 3G" when the image actually had changed, provided a cached resource was available. Possibly related to not returning a 304 Not Modified with hard reloads, despite request headers having the ETag and Last-Modified equivalent cache checks If-Not-Match and If-Modified-Since values.

Another update, while hard reload with "Slow 3G" resulted in no temporary distorted image like a soft/normal reload, which also seems to mean no img.currentSrc, hard reload with "Fast 3G" does... The inconsistency here for testing caching/image loading is a bit of an annoyance.. Loading a fresh tab(without other tab instances for the site which appears to interfere) and using "Fast 3G" or "Slow 3G" doesn't trigger the distorted image when revalidating a stale cache.


Used Cache-Control of no-cache and hard reload "Slow 3G" did not ever display the distorted TTFB duration image like soft reload does for all speeds, and hard reload "Fast 3G" was showing the distorted image, "Online" for hard reload does not and always downloads the image as if it were no-store. "Online" for soft reload was showing the distorted image too despite TTFB and content download being <2ms combined.

Created a custom network throttle that only ups latency to around 5 seconds. Soft reload reveals the distorted image until the pending request returns a response(TTFB delay), while hard reload skipped the distorted image showing the base64 placeholder until response returns. These were both testing reloads after cache was stale for public, max-age=20, although the behaviour appears the same with must-revalidate, max-age=20. The hard reload case is what matches the new tab behaviour as well.

Image distortion is conforming to the image components width/height attributes(fixed image type), with object-fit: cover only applying once the image response has finished. Distorted is shown if the cached response is used prior to revalidation response on a stale cached image.


Noted that soft reloads with the increased latency didn't show a distorted image if other assets such as js and document caches had expired. Then reduced latency and that brought the behaviour back up again, seems consistent between soft/hard reloads then. For all I know this distorted image stage is some optimization for lazy loaded images and could be ignoring cache-control expectations when cache is stale and should revalidate.


https://engineering.fb.com/web/this-browser-tweak-saved-60-of-requests-to-facebook/

The browser’s reload button exists to allow the user to get an updated version of the current page. In order to meet this goal, when you reload, browsers revalidate the page that you are currently on, even if that page hasn’t expired yet. However, they also go a step further and revalidate all sub-resources on the page — things like images and JavaScript files.

Although Chrome made changes to that behaviour (and soft/hard reload appears to have been available for a long time prior), so I'm just going to assume reloads aren't particularly reliable way to test cache-control/revalidation.. The article also links to Chrome and Firefox announcements from back in 2017 related to the FB cache revalidation optimizations.

Then there's also the mention of browser caches not being reliable/consistent here:

https://github.com/web-platform-tests/wpt/tree/master/fetch/http-cache

Some inconsistencies across implementations can be noted here:

https://cache-tests.fyi/


Chrome, Opera(Blink based), Epiphany(webkit based, similar to Safari afaik) all seem to behave similar with the currentSrc attribute having something to indicate the image is cached, but Firefox does not mimic that and always appears to be false(at this point in time when queried). So currentSrc won't work with firefox.

@mafiusu
Copy link

mafiusu commented Aug 2, 2020

I've the same problem showing a Logo on the top left side. Every time I click on a page the Logo flickers. This is uncommon. I used useStaticQuery with childImageSharp.fixed.

@polarathene
Copy link
Contributor Author

This is uncommon.

What is uncommon? The flickering of the low quality placeholder is reproducible, just need to refresh/reload or visit the page via url. It should only happen once when you visit the page, then the rest of the session it should be in the gatsby-image internal cache, which should prevent that visual appearing until the next visit of the site.

If you want to avoid it, since the logo is probably quite small, just avoid the base64 placeholder, if you look up the graphql sharp API that mentions using childImageSharp fragment, there is another one with _nobase64, or on the image object data you pass to gatsby-image fixed prop, you can set the base64 field to ""(empty string) that should work too I think.

For larger images where you want the placeholder while the real picture downloads, it's more of an issue.

@mafiusu
Copy link

mafiusu commented Aug 2, 2020

@polarathene thank you for the answer, it helped. It was the blur up effect that I found uncommon but that's because I'm new to Gatsby. I should have read the documentation further. It says:

If you don’t want to use the blur-up effect, choose the fragment with noBase64 at the end.

@github-actions
Copy link

Hiya!

This issue has gone quiet. Spooky quiet. 👻

We get a lot of issues, so we currently close issues after 60 days of inactivity. It’s been at least 20 days since the last update here.
If we missed this issue or if you want to keep it open, please reply here. You can also add the label "not stale" to keep this issue open!
As a friendly reminder: the best way to see this issue, or any other, fixed is to open a Pull Request. Check out gatsby.dev/contribute for more information about opening PRs, triaging issues, and contributing!

Thanks for being a part of the Gatsby community! 💪💜

@github-actions github-actions bot added the stale? Issue that may be closed soon due to the original author not responding any more. label Aug 23, 2020
@polarathene
Copy link
Contributor Author

Not stale.

@github-actions github-actions bot removed the stale? Issue that may be closed soon due to the original author not responding any more. label Aug 24, 2020
@github-actions
Copy link

Hiya!

This issue has gone quiet. Spooky quiet. 👻

We get a lot of issues, so we currently close issues after 60 days of inactivity. It’s been at least 20 days since the last update here.
If we missed this issue or if you want to keep it open, please reply here.
As a friendly reminder: the best way to see this issue, or any other, fixed is to open a Pull Request. Check out gatsby.dev/contribute for more information about opening PRs, triaging issues, and contributing!

Thanks for being a part of the Gatsby community! 💪💜

@github-actions github-actions bot added the stale? Issue that may be closed soon due to the original author not responding any more. label Sep 13, 2020
@LekoArts LekoArts added not stale and removed stale? Issue that may be closed soon due to the original author not responding any more. type: question or discussion Issue discussing or asking a question about Gatsby labels Oct 19, 2020
@LekoArts
Copy link
Contributor

The old gatsby-image is deprecated now, if you still see this same behavior in gatsby-plugin-image please open a new issue with a minimal reproduction. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: media Related to gatsby-plugin-image, or general image/media processing topics type: bug An issue or pull request relating to a bug in Gatsby
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants