Loading Cloud Storage Images in a Flutter Web App

by Toine Heuvelmans

June 7th, 2021

Earlier this year Flutter released web support, allowing developers to deploy their mobile applications on the web (oh, and while at it, also desktop) from the same code base. We are currently working on fine-tuning the web experience of one of our Flutter apps. The app shows a list of articles obtained from Firebase. The article IDs originate from Firebase Firestore, the images are made available through Firebase Cloud Storage. On mobile this all runs smoothly, but on the web we ran into a few issues. In this blogpost we’ll discuss those issues and the solutions we found for them.

Cross-Origin Resource Sharing

The first and most obvious issue was that the images did not load. Chrome’s Developer Tools showed the error message:

Access to XMLHttpRequest at 'https://firebasestorage.googleapis.com/…F314419080.jpg' from origin '[…]' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Images and other resources in a Flutter web app are loaded asynchronously using Ajax requests (such as XMLHttpRequest) and are therefore subject to the Same-origin security policy implemented by modern browsers, which prevents external scripts from accessing another site’s resources without permission. If the requested resource does not have the same origin (simply put: the domain) as the page that makes the request, a mechanism called Cross-Origin Resource Sharing (CORS) is used to check if the page is allowed to load the resource. 

A simple CORS request and response
An image of a cat from a different origin is requested by touchwonders.com. CORS for the image allows any origin (*) to load the image.

In its most basic form, CORS consists of a request being made which includes the Origin header, expecting a response with a header Access-Control-Allow-Origin that dictates which origins are allowed access. For “simple requests” this exchange of headers is made on the resource request itself, but in most cases a CORS preflight request is made because the actual request could potentially have implications on the server.
A preflight request uses the OPTIONS method and specific request headers to verify if the intended request method and request headers can be used. The response should contain headers informing which methods and headers are allowed, and possibly also for how long this information can be regarded valid before a new preflight request must be made. Only when the browser receives the right headers with the right values, the actual request is made.

Cloud Storage provides documentation on how to configure CORS on a storage bucket. Their example configuration shows how to allow only GET requests,  but from any origin. The maxAgeSeconds indicates browsers are allowed to repeat the request for one hour (3600 seconds) before requiring the preflight request again:

    "origin": ["*"],
    "method": ["GET"],
    "maxAgeSeconds": 3600

The configuration, saved in a JSON file, can be set on your bucket using gsutil:

gsutil cors set cors.json gs://<your-cloud-storage-bucket>

Monitoring the requests made by our Flutter web app, we now see three requests being made for each article image, all with a satisfactory status 200 (OK):

  1. The CORS preflight request
  2. The request for the image download URL
  3. The actual image download request
Three requests: CORS preflight, download URL and image

Three requests?! Yes. Though we can improve on this (keep reading), it could have been 4 requests.
Our CORS configuration uses the same maxAgeSeconds value of 3600 as in the example, allowing the browser to not have to repeat the CORS preflight for another hour. The request for the image download URL and the image itself differ only in query parameters (alt=media&token=…) being appended on the image download request, which for the CORS mechanism boils down to the same resource and thus it doesn’t trigger a preflight on the download request.

Storing The Cloud Storage Resource Download URL

Having to obtain the download URL for a resource is the default mechanism for interacting with Cloud Storage in a secure way: security rules can be employed to provide fine-grained control over who can request the download URL, and each resource has their own access token, making the download URL (which must include the token) much harder to guess.

In cases where you want to be particularly protective with who can access your resources, this mechanism also allows you to revoke a token, invalidating an earlier obtained download url. If you want to go even further, you can use a short-lived signed URL instead, which expires after a specified time. But if you don’t need all that and never have the intention of revoking the access token, there’s no reason why you cannot cache the download URL. On consecutive uses of your app this can considerably reduce the number of requests that need to be made.
We used Hive, a web-ready Dart package that provides a NoSQL-based lightweight key-value store. This approach actually persists the download URL instead of caching it; there is no rule or mechanism that evicts or expires a stored download URL. For our app this is fine, but you can consider going for a package like Stash if you do need such caching mechanisms.

In summary, one fewer request per article.

Image Caching

Remaining are the CORS preflight request that is performed at most every hour, and the download of the image, which at this point is done every time the image is requested. I hear you ask: wasn’t this a problem already on mobile?
No, because we implemented a custom caching mechanism using the path_provider package to provide a temporary directory for storing downloaded images. This package is not supported on web, so the image is not explicitly stored locally when downloaded. But don’t browsers cache images? Yes, unless you tell them not to.
There are several ways in which caching is controlled and validated, the most important one being the Cache-Control response header. It can be used to dictate if and where a resource can be cached, how long the cache remains fresh and when it should be revalidated. This header is set on Cloud Storage resource responses, with a default value of private, max-age=0. This means that the resource must be revalidated every time it is requested.
Here the Expires header comes into play: the resource is fresh as long as the response Date does not exceed the Expires date. Cloud Storage sets the Expires date to the response Date plus the max-age from Cache-Control, so with a value of 0 the resource must always be downloaded again. 

Luckily gsutil again comes to the rescue, because it can also be used to set metadata on Cloud Storage resources. This can be done on individual resources, resources matching a specific pattern, or on all resources found in the given bucket. To set the Cache-Control header on all resources in your bucket:

gsutil -m setmeta -r -h "Cache-Control:private, max-age=86400" gs://<your-cloud-storage-bucket>
  • The -h argument can be used to specify a single header that should be set
  • The -r argument allows you to specify just the bucket, not a name or pattern
  • The -m argument (on gsutil) will run setmeta in parallel, speeding up the process

Now the images will be cached and, as long as the CORS preflight does not need to be performed again and the cached image is fresh, no requests will be made anymore – at least for a day.