Dansday

The Month Live Streaming Shipped Twice

Published on May 31, 2025

Fifteen pull requests in May and thirty-four contributions. Nine of the fifteen belong to one feature, which shipped on 22 May, was removed on 27 May, and shipped again on 30 May in a larger form.

It is the cleanest example I have of a feature being correct and still being wrong — because of where it was, not what it did.

A floating header for TikTok

The ticket asked for something 3cat had not had before: live shopping.

As a user browsing the website, I want to see upcoming and ongoing
livestreams highlighted, so that I can engage deeply with store staff,
access special deals, vouchers, and exclusive insights.

Store staff run TikTok livestreams from the shop floor. The site needed to know when one was on, whose it was, and put a banner across the top of every page pointing at it. The acceptance criteria included a CMS screen for scheduling, with validation that reads like it was written by somebody who had already seen the mistakes coming:

- Show a warning if a livestream exceeds 3 hours.
- Show a warning if a livestream ends in the past.
- Show a warning if a livestream is scheduled more than 1 month into the future.

Three warnings rather than three hard errors, which I think is the right call. A four-hour livestream is unusual and not impossible; a stream scheduled six weeks out is probably a typo but might be a plan. Warn, and let the person decide.

The final shape was a 552-line admin screen for scheduling streams per store, a repository, 190 lines of it, and a view composer.

Everywhere is expensive

That view composer is the interesting decision and the source of everything that went wrong.

3cat/src/View/Composers/StoresLiveStreamComposer.php    +54

A floating header has to appear on every page, which means every page needs the livestream data, which means either every controller passes it or something binds it globally. A view composer is the correct answer to that — it attaches the data to the header view once, and no controller has to know.

It is also a decision that converts anything the feature does into something the site does on every single request. And the feature did two things that were fine once and expensive always.

Shipped, reverted, reshipped

The timeline, from the pull request dates:

6 May   Floating Live Streaming Header opened      +1429/-129, 25 files
22 May  merged after sixteen days in review
22 May  Optimize location logic merged
26 May  Live Streaming Updates1 opened             +720/-1617   (closed)
27 May  Revert "Optimize location logic"           merged
27 May  Revert "Floating Live Streaming Header"    merged
28 May  Cache refresh hotfix                       merged
30 May  Floating Live Streaming Header, again      +1801/-291, 30 files

Five days live, then both pull requests reverted on the same afternoon. Then the infrastructure fix. Then the feature again, 372 additions larger than the version that had just been taken out.

I have written about reverts twice now — February's store locator, where the hotfix deleted the feature, and April's, where I reverted a colleague's work rather than keep patching it. This one is the version I would defend without qualification, because the revert was not the end of the story. It was a way of buying five days to fix the thing underneath, with the site in a known state while I did.

Cache invalidation as a billed API

The first expensive thing was cache invalidation. Every category update fired a CloudFront invalidation immediately:

- CloudfrontRefreshJob::dispatch([$this->pathResolver->resolveCategoryPath($category->slug)]);
+ RateLimitedCloudfrontRefreshJob::dispatch([$this->pathResolver->resolveCategoryPath($category->slug)]);

That had been survivable while content changed a few times a day. A livestream schedule changes constantly — a stream starts, ends, is rescheduled, and the header has to stop being cached wrong — and CloudFront invalidations are a rate-limited, metered API belonging to somebody else.

Eighty-five lines fixed it, and the logic is worth reading because it is mostly about recognising redundancy:

if (in_array(self::FULL_INVALIDATION_PATH, $uniquePaths)) {
    $this->scheduleInvalidation(self::FULL_INVALIDATION_PATH, $now);
    return; // Skip processing other paths since we're doing a full invalidation
}

if ($fullInvalidationData) {
    Log::info('Skipping individual path invalidations due to scheduled full invalidation:', [...]);
    return;
}

If the batch contains a wildcard invalidation, nothing else in it matters. If a wildcard is already queued, no individual path matters either. Everything else gets scheduled once, ten minutes out, with a marker in the cache so a second request for the same path within that window does nothing:

Cache::put($cacheKey, [
    'scheduled_time' => $scheduledTime->timestamp,
    'path'           => $path
], $scheduledTime);

CloudfrontRefreshJob::dispatch([$path])->delay($scheduledTime);

The detail I like is the cache entry's own expiry being set to the scheduled time. The marker dies exactly when the job runs, so the window cleans itself up and there is no separate bookkeeping to get wrong.

What this actually is, though, is debouncing. The same pattern as a search box that waits for you to stop typing, applied to a content delivery network. Ten minutes of staleness traded against an unbounded number of calls to a metered service.

A lookup nobody needed

The second expensive thing was more embarrassing and smaller.

When several stores are streaming at once, the header should show the one nearest the customer. So the repository looked up the visitor's location:

- $position = Location::get();
- $userLat = $position ? $position->latitude : null;
- $userLng = $position ? $position->longitude : null;

Those three lines sat at the top of the method, before any of the branching. The method runs from the view composer. The view composer runs on every page. So every visit to every page performed a geolocation lookup — the feature had brought a MaxMind GeoIP database in with it — in order to answer a question that only arises when two stores happen to be streaming simultaneously.

+ // Only check location if there are multiple ongoing streams
+ $location = $this->getUserLocation();
+ if ($location) {
+     return $this->findClosestStore($ongoingStreams, $location['lat'], $location['lng'], '1');
+ }

Move the lookup inside the branch that needs it. One stream on: no lookup. No streams: no lookup. Two streams at once, which is rare: one lookup. The fix is moving three lines down twenty.

This is the same mistake as May 2024's promotion queries and July's similar-products block, and I want to name what is actually common to them. It is not that I do not know to be careful about work in loops or hot paths. It is that I keep failing to notice when a piece of code has quietly become a hot path. A repository method is not a hot path. A repository method called by a view composer is every page on the site, and nothing in the file says so.

The New Store labels, again

On 23 May I merged a ten-line pull request called Remove the New Store labels.

In March, one of the six Hotjar findings was that almost every store carried a New Store badge, so I removed them from all but one. In April I merged Clean up New Stores. In May I removed them again.

Three passes in three months at the same label, because the badge is set per store in a config file and nothing expires it. Every time a shop opens, somebody adds the label, and it stays until a human notices the page is claiming eight new stores. A field that means recently with no date attached to it will always drift, and the fix each time is to delete the value rather than to give it an expiry.

The rest of the month was small and worth listing for honesty: aligning the value propositions between the header slideout and the footer, linking the quality-control page from the Great Condition slideout, stopping the chat button from covering popups, a one-line design fix, a Mother's Day change I closed without merging, and a hotfix for store selection on reservations the day after the livestream header went back out.

Ten lines that outlived the feature

A hotfix I did not mention: ten additions, one deletion, one file, titled Store selection reserve (Hotfix). It is the smallest thing in May and it turned out to be the most durable.

const urlParams = new URLSearchParams(window.location.search);
const placeId = urlParams.get('place_id');
const savedShipping = placeId ?
    { method: 'freereservation_freereservation', isFullyPaid: false } :
    JSON.parse(sessionStorage.getItem('selectedShipping') || '{}');

If the URL carries a place_id, the checkout assumes a free reservation and ignores whatever the customer's session remembered. It was written to fix one thing: arriving at checkout from a store link and finding the wrong shipping option selected.

Nothing else in this article lasted as well. The livestream header shipped, was reverted and reshipped within eight days. The geolocation optimisation went in, came out and went back in. But place_id became a permanent piece of the checkout's interface, and everything that touched it afterwards had to know about it. In October a colleague filed a bug because the nearest-store guess was overriding a store the customer had already chosen, and the fix was to check for exactly this parameter and return early. In November it became the foundation of the templated checkout link, where a chatbot hands a customer a URL with shipping method, store and deposit amount already in it.

Three articles' worth of consequences from a ten-line hotfix, and the hotfix has no ticket body and a title in brackets. This is the counterpart to the point I make about the livestream header: the changes that turn out to matter are not reliably the ones that look big while you are making them.

Two things fighting for the same layer

May has a chat-button collision in it, which this article covers — twenty-nine additions across ten files to stop the floating chat button sitting on top of slide-outs and popups. What it does not mention is that the same month contains a second one, in the opposite direction:

-  <div class="... z-50 top-0 left-0 bottom-0 fixed hidden"
+  <div class="... z-[60] top-0 left-0 bottom-0 fixed hidden"

The slide-out panel, its dimming background and its close button all moved from z-50 to z-[60]. Two pull requests in one month adjusting which thing wins when two floating elements overlap, solved by moving one of them up a layer.

I have written about the chat button colliding with whatever sits above it in January, and I will again in June. The pattern is not really about the chat button. It is that this interface has a growing number of things that float above the page — a chat button, slide-outs, an exit popup, a sticky header, a variant panel — and no shared scale saying which outranks which. Each collision is fixed by picking a bigger number than the thing it lost to.

The same pull request did something worth more than its four lines. The Great Condition slide-out — the panel explaining what condition a used device arrives in — gained a link to the quality-control page. For eighteen months that panel had asserted a standard; from May it pointed at the process behind it. It is the first move in a thread that does not resolve until January 2026, when "Great Condition" is replaced everywhere by "56-step checks".

What May was

Fifteen pull requests. Live shopping on the site, a CloudFront bill that stopped growing, and a geolocation lookup that now happens roughly never.

The lesson I take is about placement rather than code. Both of May's problems were caused by putting something in the one place that runs on every request, and neither would have been visible in review — the invalidation call looks reasonable in a listener, the location lookup looks reasonable at the top of a method. They only became expensive because a view composer sat above them. Five days in production found both, which is faster than any amount of reading would have.

Revised: this article originally covered the livestream feature and the geolocation reverts thoroughly and omitted two small pull requests — the place_id hotfix that three later articles depend on, and a second z-index collision fix that also added the quality-control link to the Great Condition slide-out. The sections above were added from the original diffs.