Boosting sales with browser cache!?!
Recently one of our customers informed us that they were getting weird numbers from Facebook and Google in regards to order purchase amounts on their Solidus based eCommerce.
Facebook and Google reported multiple order confirmations with the same order number, the actual order total amounts were from 2 to 6 times lower than what was reported by them.
These numbers looked very nice on paper 📈 and could boost some distracted egos (people are buying plenty of stuff, we’re doing much better than expected!!! 💵🤑💰) but unfortunately they were so inaccurate to be pretty useless. Houston, we have a problem 👨🚀.
How do we inject the JS code only once? By leveraging a feature in Solidus frontend
codebase: a convenient
flash message is set after the order is finalized, and it can
be used as a flag in order to trigger the tracking libraries.
What follows is the relevant code in Solidus codebase. Everything starts
#finalize_order is called and eventually the message is set in
# solidus/frontend/controllers/spree/checkout_controller.rb def update if update_order assign_temp_address unless transition_forward redirect_on_failure return end if @order.completed? finalize_order else send_to_next_state end else render :edit end end private def finalize_order @current_order = nil set_successful_flash_notice redirect_to completion_route end def set_successful_flash_notice flash.notice = t('spree.order_processed_successfully') flash['order_completed'] = true end
And this is the code in our customer’s eCommerce that uses the
<% if flash['order_completed'] %> <%= render "facebook_tracking" %> <%= render "google_remarketing" %> <%= render "adwords" %> <% end %>
The usual suspect for such issues is caching. If the page is cached (either on the server or the client) then the HTML (JS code included) is not fresh and will trigger the tracking events multiple times, one time for each page view.
Testing locally and on the staging environment (which happens to mirror production in regards to cache settings and such) confirmed that events are correctly triggered only once: the expected JS code is present on the first page load but is not on subsequent visits.
Soon we were able to confirm the issue with Safari on iOS. If you complete the order and close the browser/restart the device then, when you reopen Safari, the page is reloaded from the cache, not from the web.
The next step was to check the page HTTP headers. This can be done easily and
quickly from the command line using
curl -I <page-url>.
The response included these results:
HTTP/1.1 200 OK Cache-Control: private Content-Type: application/html
Let’s get some help from MDN website in regards to the
[private] Indicates that the response is intended for a single user and must not be stored by a shared cache. A private cache may store the response.
So, this directive does not completely exclude caching.
The fix was easy: customize the HTTP headers to completely avoid caching. This was
done with regular Ruby on Rails code by extending the controller
OrdersController in Solidus frontend with a decorator:
module OrdersController module EditActionCacheHeaders def self.prepended(base) base.before_action :set_no_cache_headers, only: :show end private def set_no_cache_headers response.headers['Cache-Control'] = 'no-cache, no-store' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = "Fri, 01 Jan 1990 00:00:00 GMT" end Spree::OrdersController.prepend self end end
When changing Solidus default behavior, it’s better to do it in a decorator that extends the
default functionality with
prepend rather than reopening and patching its methods directly.
This solution allows for easier maintenance, more clarity of purpose and composition.
The order page is managed by
OrdersController#show action, which we don’t need to modify
at all as adding a new
before_action suffices to achieve the goal.
The first line of
#set_no_cache_headers method is the most relevant, as it instructs all
recent browsers (HTTP/1.1) to absolutely not cache and store the page for any reason.
The second line
response.headers['Pragma'] = 'no-cache' targets older browsers (HTTP/1.0)
that can accept only the
Pragma header, while the third line set the
Expires header well in
the past, as an extra safeguard just in case the previous headers failed their purpose.
before saying goodbye let me share a much cited quote from Phil Karlton:
There are only two hard things in Computer Science: cache invalidation and naming things.
Have a good one!