In a web application we have recently built using Vue.js and Firebase, we needed an image uploader that enables the user to upload images (e.g. a profile picture). After the upload the image should be scaled down and resized to a set of predefined sizes. For that, we leverage the very neat Firebase extension Storage Image Resize. This extension is easy to configure and use. It gets the job done: every time a new image is uploaded to Firebase Storage, it starts the resizing and adds the scaled images to the same folder.
Disclaimer: This article assumes prior knowledge in Vue, Vuex and Firebase and will not cover those basics. It's not a tutorial but instead conceptualizes a software engineering pattern.
Update from July 2022: Resized Event available
Finally, Firebase added an event to know when the resizing is done. See Changelog
Problem
But in the simplicity of the server-side resizing there also lie problems: The frontend application neither knows if or when the backend process has started nor completed. It is totally independent. The only way to know if the process has been completed successfully is to check if the scaled files are present on the Storage.
The resize extension is a cloud function and based on our tests we experienced execution times between 200ms and 2s (cold-start). Depending on the uploaded image size, the process can also take longer. In our configuration the function needs to create 4 predefined image sizes. Therefore there could be cases where the resized image is not yet ready to be used in the frontend application if called in a sequential way. So how can this potential unknown delay in process be solved?
Solution
First of all: there are different solutions to this problem. We sought for a solution that has (1) a high cohesion with the uploader component itself and does not require additional backend jobs and (2) can fail nicely with a fallback solution.
Here is a schematic depiction of the process:
In our real application we implement the following workflow:
- User selects file [Uploader Component]
- Upload action uploads file to Firebase Store [Vuex Store Action]
- Upload action returns the DownloadUrl for the storage object [Vuex Store Action]
- In parallel, on server side, the resize cloud function starts to process the images triggered automatically by the upload[Uploader Component]
- User is informed in the UI that the resizing starts [Uploader Component]
- The Uploader Component triggers the function to get the resized image url (
tryGettingResizedDownloadUrl()
) [Vuex Store Action] - The
tryGettingResizedDownloadUrl()
function will try to create a DownloadUrl. If it fails, it will wait 800ms and then retries again by calling itself recursively. As soon as it finds a downloadUrl for that image, this Url is returned. It finds a download url by repeatedly checking if the file exists on the storage filesystem which matches the defined filename pattern (filename_${width}x${height}.{fileExtension}
). After x-retries it stops and returnsnull
. - If the
tryGettingResizedDownloadUrl()
-function does not deliver a result and returnsnull
, the full-size image is stored in the database as a fallback.
Why a naïve approach?
A naïve implementation is an implementation that has taken shortcuts for the sake of simplicity or by lack of knowledge (e.g. when the backend completed). It will not account for all the possible uses cases or try to fit in every situation. In potentially 99% of the cases, this naïve wait-and-try-again will be successful. Of course there can be other failures, but these are edge cases. We optimize for the standard case and make sure to fail nicely if something unexpected happens. This makes sure that other parts of the application are not affected by this implementation.
Implementation
The Vuex Action in the storage.js
Vuex-Module.
Here we have implemented the naïve wait-and-try-again approach:
As you can see, the retry is implemented as a try-catch-block. In case of failure, the retry variable is increased, the action waits 800ms (await sleep(800)
) and the recursively calls itself again (return dispatch('tryGettingResizedDownloadUrl', { url, size, retry })
). It is crucial to add the return
before the dispatch()
, otherwise the action won't return anything.
In our frontend Vue Photo Upload component we interact with the Vuex action:
As you can see, the resizedUrl
variable awaits the the results from the action and is either a string
containing the URL or null
. In case of the latter, fullImageUrl
is saved.
Conclusion
For us, this naïve retry-approach is a very feasible option. Whenever the naïve approach fails we receive a ticket in Sentry - our application monitoring platform. We acknowledge that there are more secure solutions. But retrying makes a lot of sense in our case and it makes sure the upload and resizing is cohesive and implemented in a step-by-step process.
What do you think about this solution? Let us know in the comments.
Template
Here is the template for a recursive retry function in Vuex:
Top comments (2)
I'm trying to solve the exact same issue! Very interesting read, you say this method is naive and doesn't cover all use cases, Isn't this a situation that happens a lot? What is the method by-the-book to cover a situation like this?
Thanks Miguel. It’s naive because i do not have any special heuristic if 10 times retry is enough. No model, network properties or image properties.
I did some research back then but could not find a by-the-book solution. If have this code in production and so far it works very well. I monitoring it with Sentry and it works reliable