Case studies

Thirteen problems, and how each one was actually diagnosed.

Every number here comes from live client work and was checked before and after. Where the useful finding was that a client should spend less rather than more, that is included too, because those are usually the ones worth reading.

Verified on live sitesBefore and after evidenceEvery number traceable to a case

Recently shipped

The latest work off the bench.

A running list of what has gone out recently, most recent first. The detailed case studies are below; this is the short version of what has been keeping us busy.

  1. Technical SEO Claims, checked A schema audit and a crawl of all 1,367 sitemap URLs, against a site asserting things nobody had verified: 246 events emitting a "from" price as the price, and 336 claiming ticket availability, 209 of them because the field layer was handing back its own default at read time. Ranges now emit low and high, unchecked entries assert nothing, and a permanently closed attraction reads as cancelled at its own URL rather than being deleted.
  2. Performance 1,438ms to 45ms A front page at 5,968ms cold, profiled on the server rather than guessed at. The cost was one query, not the number of them: a date overlap expressed as a standard metadata query compiles to a four way self join on a column the platform does not index. Replaced with a date index read in a single indexed query, the overlap worked out in code. Front page to 1,994ms, and all ten sampled pages byte identical afterwards.
  3. Technical SEO 65 URLs, both ways Sixty five URLs were being submitted for indexing and refused indexing at the same time. The noindex rule ran through a filter that only fires while a page renders, and the sitemap is built from the database and never rendered anything. The threshold now lives in one function both sides read, with a count of what an archive would actually show rather than a raw total.
  4. Custom build Near me search A proximity search for a London listings publisher, over 968 dated listings across 45 areas. Server rendered in full so a crawler reads a finished page, with the visitor position resolved in the browser and never transmitted: the distance sort runs locally against area centroids, so the site holds no location data because none is collected. Six facets on top, by proximity, day, time of day, category, area and budget.
  5. Custom build Live rankings A popularity ranking driven by real behaviour rather than a hand kept list, with most visited and most voted as two separate modes. Server rendered with the counts in the HTML so a crawler reads a finished page rather than an empty container, voting rate limited to one per profile per day, shipped as its own paginated page and a homepage module.
  6. Build and SEO 47 service pages A service taxonomy built out to 47 landing pages from one template, each on its own URL and populated from real roster data. Surfaced through a browse gallery on the homepage so every page carries an internal link from the strongest page on the site instead of being reachable only through the sitemap.
  7. Build and SEO Profile system built A custom profile and listing system for a London agency, coded from scratch: consistent templates, gallery and media handling, and an internal structure that lets new profiles be added without breaking the layout or the SEO underneath. Retained for the search work since.
  8. Product build Bookings to insight A booking tool grown into a standalone product: its own accounts and hosting, a private ledger, and an analytics layer with period comparison, revenue breakdowns and repeat client ranking. Installable, and it loads in 236ms.
  9. Custom build Faceted search A full navigation with comprehensive search built into it for a large member directory. Live categories, an available now filter, reviews and a booking flow, all in one mobile first menu.
  10. Security 21,626 to 0 A brute force run against a login page, stopped at the edge in fifteen minutes. The server was only at 7.5% load, so the bigger box on the table would have fixed nothing.
  11. Infrastructure 24 hrs to instant Four independent gaps behind one cache failure, all four fixed, then the purge rewritten so it stops flushing the image cache on every edit.
  12. SEO architecture 25 to 5 A DR 72 domain ranking for nothing. Competitors sat at DR 16 to 32, so links were never the constraint. Rebuilt as a hub with chosen internal links.
  13. Structured data One valid entity Years of overlapping plugin claims consolidated into a single graph, and the Search Console entity issue closed with it.
01
Build and SEO

A profile system a London agency runs itself

The problem

A London agency needed a real profile and listing system rather than a page builder wrestled into shape. The roster changes constantly, and every earlier attempt meant a developer touching the layout each time a profile was added, which was slow, expensive and a reliable way to break the SEO on the pages that already ranked.

The diagnosis

The site is its roster, so the profile template is the product. A generic post type gives you none of the structure an agency actually uses, and a marketplace theme cannot be moderated, verified or made to load. What was needed was a bespoke template and data model built for the way the agency works, with adding a profile designed as a first class action rather than a developer job.

The fix

A hand coded profile and listing system: one consistent template, gallery and media handling built for image heavy pages, and an internal linking and taxonomy structure so a new profile slots in without touching the layout or diluting the pages around it. Each profile is server rendered and genuinely crawlable, not assembled in the browser after the crawler has left.

The result

The agency adds and edits profiles itself, with no developer in the loop, and the layout and the SEO hold every time. The pages stay fast as the roster grows because the template outputs only what the page needs, and the search work continues on top of a build that does not fight it. The client is not named, as is usual in this sector.

02
Product build, SaaS

A booking tool that grew into a product

The problem

The operator was running bookings and client history across a notes app and a spreadsheet, on a phone, between jobs. Nothing was aggregated, so questions as basic as which clients were worth keeping had no answer without adding it up by hand.

The diagnosis

The work did not belong on the website. Putting it there would have meant a busy booking day competing with public traffic, and it would have tied private data to a public CMS. What was needed was a separate application with its own accounts and its own hosting, built for a phone first because that is the only device it gets used on.

The fix

A standalone app with sign up, sign in and password recovery, a bookings ledger and private notes. Then the layer that made it worth opening daily: analytics with period comparison, revenue breakdowns by booking type, and repeat client ranking. Built as an installable progressive web app with a service worker, so it sits on the home screen and survives a patchy connection.

The result

It loads in 236ms from four requests, which is the difference between a tool someone uses and a tool someone avoids. Deployed separately from any public site, so neither can take the other down, and the questions that used to need a spreadsheet are now answered on the dashboard.

03
Security, edge mitigation

A login page taking half the traffic on the site

The problem

The site felt slow and the obvious next step, the one that had already been suggested, was to pay for a bigger server. Before agreeing to that we looked at what the traffic was actually made of.

The diagnosis

Live server log analysis showed /wp-login.php absorbing about 46% of every request reaching the origin. That is a brute force attack running at 21,626 hits a day. But the second finding mattered just as much: the server was sitting at 7.5% CPU. The attack was noisy, not heavy. It was not what was making the site feel slow.

The fix

We put a Cloudflare Managed Challenge in front of the login path with a skip rule allowlisting the admin IP, so the attack got stopped at the edge without locking out the person who needs to log in.

The result

Hostile traffic dropped to zero within fifteen minutes, verified in the logs, with legitimate admin access confirmed working. Just as valuable, the client did not spend money upgrading a server that was running at 7.5% load. A misdiagnosis there would have cost real money every month and fixed nothing.

04
Infrastructure, diagnosis

Four separate root causes behind one cache failure

The problem

Live availability changes were not showing up on the site. Some pages were up to a full day out of date. Everyone assumed it was one bug.

The diagnosis

First we had to establish whether the problem was the file or the cache, which meant testing the origin directly with a cache buster. That confirmed the origin was correct and the cache was serving stale content. The deployed purge turned out to be a targeted URL list rather than a full purge, and there were four independent gaps in it: URLs simply missing from the list, a meta deletion path with no hook attached, a second URL form for location terms that the purge never accounted for, and the subtle one, curated pages that query content rather than being taxonomy archives, so no term based loop could ever reach them. A fifth finding came out of the same read. An earlier implementation had been calling purge everything on every content change, which also flushed the one year image cache, so the AVIF files were perpetually cold. Seven of eight profiles sampled were returning MISS. Nobody had connected slow images to the purge logic, because they looked like two different problems.

The fix

All four fixed rather than one. That last cause is the kind of thing that survives three rounds of debugging, because the code looks correct and the loop it runs in simply never covers those pages. We then rewrote the purge itself as a targeted, HTML only invalidation that never touches images: rate limited to one purge per 60 seconds, chunked at 30 URLs per API call, and run non-blocking on shutdown so an editor pressing publish never waits on a CDN. Triggers were wired to every way state actually changes, including meta deletion, status transitions and comment events, and location terms are purged in both URL forms because one is canonical at the root while the other 301s to it. Image operations auto-purge too: optimisation plugins that write metadata directly are hooked through their own action, attachment IDs are resolved to every generated size, and purging a source jpg also evicts the AVIF derived from it.

The result

Six pages that had been stale for 14 to 24 hours matched the origin immediately after deployment, verified page by page rather than assumed, with edge ages of 43 to 47 seconds. The image cache stopped being flushed on every edit, so profile images now serve as AVIF with a cache HIT and stay warm for around 19 hours and beyond.

05
SEO architecture, content

A DR 72 domain ranking for nothing on a page it should have owned

The problem

A strong domain was ranking for zero keywords on a target page. The assumption, as it almost always is, was that the page needed more links.

The diagnosis

Before agreeing to a link campaign we checked who was actually ranking. The page one competitors were sitting at DR 16 to 32, well below the client. That proved authority was not the constraint, so link building would have been expensive and useless. The real causes were on the page: a canonical pointing at a redirect, two pages competing for the same intent, machine spun copy, and a 25 link block spreading equity across everything indiscriminately.

The fix

Rebuilt as a hub with spokes, splitting commercial and informational intent apart so the pages stopped competing. Corrected the canonical, replaced the spun copy with writing a person would finish reading, and cut the link block to five chosen internal links.

The result

The structural causes were removed and the page became eligible to rank on its merits. We also left behind a linking governance rule with a live ledger, so the tidy structure does not decay back into a link dump once we are gone.

06
Structured data

A schema graph quietly fighting itself

The problem

Search Console was reporting an entity issue and the structured data was a mess of overlapping claims made by different plugins over several years.

The diagnosis

There was a duplicate organisation entity, per item LocalBusiness markup that should never have been there, self serving review ratings, and invalid FAQPage markup, all live at the same time. Each individually looked defensible. Together they described a business that did not make sense.

The fix

Removed the duplicate organisation entity, disabled the per item LocalBusiness markup and the review ratings, promoted the remaining node to a single correct type, and stripped the invalid FAQPage markup. We deliberately kept the breadcrumbs and the #organization @id after researching the collision risk rather than removing them out of caution.

The result

One valid entity, validated through Google Rich Results, and the Search Console issue closed.

07
Product, bespoke tooling

Two customer facing tools built from scratch

The problem

The site needed two features that no plugin covered properly, and both had to stay fully crawlable because they were targeting real search demand.

The diagnosis

The first was a location search targeting a term with 33k searches a month. The obvious implementation, a JavaScript widget that fetches results on the client, would have been invisible to crawlers and worthless for the actual goal.

The fix

We built a geolocation search using the Geolocation API, Haversine distance sorting, radius filtering and reverse geocoded location labels, server rendered so it stays crawlable. Its geocoding layer maintains itself: a static map first, then a term meta cache, then a one off Nominatim lookup, which means new locations work with no code change at all. The second tool was a three way comparison built on real relationship data derived from post meta and made bidirectional, with SVG connector graphics, location aware combined pricing, localStorage persistence and undo and redo. DOM safe rendering throughout.

The result

Both are live, both are crawlable, and the location tool has needed no developer involvement to add locations since it shipped.

08
Custom build

A popularity ranking that a crawler can actually read

The problem

A large London directory needed to answer the question every visitor actually arrives with, which is who is popular right now. Editorial curation answers it badly: somebody picks favourites, the list is stale within a week, and it tells the visitor what the operator wants to promote rather than what is true.

The diagnosis

Two hard parts, and they pull against each other. The ranking has to come from real behaviour rather than a hand kept list, which means live counters. But the module also has to be readable by a crawler, and the obvious build fetches the counts in the browser, which leaves a search engine looking at an empty box on the one page most likely to earn a listing. The second trap is caching: live numbers on a cached page either serve stale counts or defeat the cache and make the page slow.

The fix

Rankings are server rendered with the counts in the HTML, so the page is complete before any JavaScript runs. Two modes sit behind one toggle, most visited by profile views and most voted by a separate count, so the popular-by-traffic and popular-by-preference questions stay distinct rather than being averaged into one meaningless score. Voting is rate limited to one vote per profile per day, which is what stops a ranking becoming a contest of who has the most patient friend. It ships as its own paginated page at twenty per page and as a condensed module on the homepage.

The result

The rankings update from real behaviour with nobody curating anything by hand, and the page is fully crawlable rather than an empty container. The counts shown are the real ones, including the small ones, which is a deliberate choice: a ranking that quietly inflates its numbers is worth nothing to the visitor trying to use it. The client is not named, as is usual in this sector.

09
Build and SEO

Forty seven service pages from one template

The problem

The roster was browsable by name and by location, which are the two axes a directory always has and neither of which is how a large share of the demand is actually phrased. People search for a service, and there was no page that answered any of those searches.

The diagnosis

Service is a real search axis with real volume behind it, so the pages are worth having. But 47 generated pages is programmatic SEO, and it carries the failure mode that always comes with it: thin, near identical pages competing with each other, eating crawl and diluting the listings that already worked. The deciding question is whether there is genuine inventory behind each page, because a service page with nothing on it is a doorway.

The fix

One template driving 47 service pages, each on its own clean URL, populated from real roster data rather than spun copy. Surfaced through a browse gallery on the homepage so every one of them carries an internal link from the strongest page on the site, which is the step that usually gets skipped: a taxonomy page reachable only through the sitemap has no internal authority and reads as peripheral. The nationality and location axes are built the same way, so the three ways people actually narrow a search each have a real page behind them.

The result

Forty seven indexable landing pages matching how the demand is phrased, all reachable from the homepage rather than sitting orphaned. The honest caveat is stated for the client too: these earn their place only while there is real inventory behind them, and a service page that empties out should be pruned rather than left to thin the set. The client is not named, as is usual in this sector.

10
Custom build

A near me search that never learns where you are

The problem

A London listings publisher needed to answer the near me question properly. That phrasing is how people actually search when they have already decided to go out and only want to know what is close, so the page had to earn the term rather than gesture at it.

The diagnosis

Two traps, pulling in opposite directions. Build it as a browser widget that fetches results after load and a crawler sees an empty container on the one page meant to rank for the term. Send the coordinates to a server to sort them instead and the build has quietly acquired a location dataset, which is a consent and retention problem the publisher did not want and did not need, because the sorting maths is small enough to run anywhere.

The fix

The listings are server rendered in full, so the page is complete and crawlable before any script runs. Position is resolved in the browser and never transmitted: the Geolocation API returns a reading, the distance sort happens locally against area centroids, and nothing leaves the device. Six facets narrow it from there, by proximity, day, time of day, category, area and budget. The precision is stated rather than implied, in the page copy: these are straight line miles to the middle of an area, not walking directions, which is the honest description of what the maths actually produces.

The result

The near me answer is fully readable by a crawler and the publisher holds no location data at all, because none is ever collected. Free entries are marked, everything carries the venue link so a visitor can confirm before travelling, and the listings carry no sponsored or affiliate entries. Ongoing maintenance and site administration continue alongside. The client is not named, as is usual in this sector.

11
Performance

Four queries, and one of them was the entire page

The problem

The front page of a listings site took 5,968ms cold. The usual advice at that point is to reduce the number of queries, which would have been wrong here and is worth showing why.

The diagnosis

Profiled on the server rather than guessed at. One function was 3,389ms of a 3,465ms build, and it ran in four queries, so query count was never the fault. The expensive part was asking which events overlap a date window. Expressed as a standard metadata query, that compiles to a four way self join filtering on a column the platform does not index by design: 1,438ms for that single query, against 13ms to load the 362 rows it returned. The first guess was wrong and is worth recording too. We expected the win from hoisting repeated field reads out of the sort comparator; that returned 21%, because the field layer already caches per request. The structural tidy stayed, but the query was the actual fault.

The fix

Replaced with a date index: every published event start and end held in one cached structure, read by a single query against an indexed column, the overlap worked out in code, then the matching events fetched by id. The expensive shape stops being asked for.

The result

That query went 1,438ms to 45ms and the front page 5,968ms to 1,994ms. The check that matters is the last one: all ten sampled pages were byte identical before and after, so this bought speed and changed nothing a reader or a crawler sees. The client is not named, as is usual in this sector.

12
Technical SEO

Sixty five URLs asking Google for two opposite things

The problem

Sixty five URLs on the same site were being submitted for indexing and refused indexing simultaneously. Each one carried a noindex tag and a sitemap entry asking Google to index it.

The diagnosis

The two systems could not see each other, and neither was wrong on its own terms. The noindex rule ran through a filter that only fires while a page renders. The sitemap is built from the database and never renders anything, so it never called that filter and had no idea the rule existed. Any page thin enough to be held back was therefore still advertised, and nothing in either half could detect the contradiction.

The fix

The threshold moved into one function that both the noindex rule and the sitemap read, so there is a single source of truth rather than two rules that agree until one is edited. Alongside it, a count of what the archive would actually display, built with the same query the page itself uses, rather than a raw total that counts entries the visitor will never see. Cached for the day and cleared nightly, so an archive that gains an entry is back in the next day's sitemap on its own.

The result

The sitemap and the pages now say the same thing: pages 121 to 120, venues 148 to 87, areas 45 to 43. The honest part is that this was only one of two valid fixes, and the other one belongs to the owner rather than to us. Making the sitemap agree with the noindex is what we did because it matched the rule already in the theme. Removing the noindex and letting those pages be indexed is the opposite call, it is defensible, and it is one number to reverse. The client is not named, as is usual in this sector.

13
Technical SEO

Everything the site was asserting that nobody had checked

The problem

Structured data is a claim made to a machine, and the site was making a great many it had never checked: prices, ticket availability, dates, and at least one attraction that no longer existed. A wrong assertion is worse than no assertion, because it is the one a search engine will repeat.

The diagnosis

Three separate mechanisms, all producing the same class of failure. 246 events emitted a "from" price as the price, turning a range into a fixed figure. A ticket field defaulted to available and could not be left empty, so 336 listings shipped an in stock claim, and 209 of those were the field layer handing back its own default at read time, meaning nobody had ever even seen the value it was asserting. Then a crawl of all 1,367 sitemap URLs turned up something a schema audit never would: not link rot but a wrong fact. A fireworks display was listed on the wrong night, because the council runs two displays at two parks on two dates, and the listing had one venue against the other one's date. The council page was internally consistent. The site was simply wrong, and reading it would never have shown that.

The fix

Ranges now emit as an aggregate carrying a low and a high figure rather than one number pretending to be the price. The ticket default was changed so it asserts nothing, which corrected 209 listings on its own, and the remainder were cleared to a not checked state that reads "See venue" and emits no availability claim at all, so sold out and selling fast now appear only where somebody actually looked. And an attraction that has closed permanently got its own state rather than deletion: cancelled in the schema rather than scheduled, a closed badge on the row, the booking button replaced by a link to the venue, and a filter so nothing that means "what is on" can ever carry it.

The result

Every claim the site still makes is one somebody checked. Keeping the closed attraction at its own URL rather than deleting it is the deliberate part, because people go on searching for a thing that ran for sixteen years long after it stops, and a page that explains it has closed serves that search better than a 404. The knock on is the detail worth keeping: removing it meant a guide naming four venues and calling one of them the cheapest of the four was suddenly counting wrong, so that sentence was corrected in the same pass. The client is not named, as is usual in this sector.

Engagements

Who this work was for.

Flagship, DR 64

High traffic lifestyle directory

We owned the whole technical side. Custom Sage theme, two bespoke customer facing tools, the full caching architecture, structured data, edge security, SEO strategy and copy. Around 151 hours logged and documented.

Roughly 9,676 images were rebuilt into a custom three size plus AVIF pipeline, served with a one year max-age and returning cache HIT.

What that produces, as field data in CrUX on throttled mobile: gallery template LCP 372ms with INP 51ms, homepage LCP 1032ms with INP 78ms, and CLS 0.00 across the home, gallery and profile templates. The profile template measures a lab LCP of 1528ms with CLS also at 0.00. Lighthouse SEO is 100 out of 100, listed last because it is the least interesting number of the set.

Retained

Lamplit London

A full custom web design and build, not a page builder theme dressed up. The design was drawn for the brand and coded by hand, so the templates stay light, the markup stays clean, and there is no plugin bloat carrying layout it does not need.

Bespoke theme development across the directory, editorial and landing templates, laid out to read well from a narrow phone up to a wide desktop. Sponsored placements are handled properly with rel="sponsored" so the commercial links stay compliant, and the internal linking is built as a deliberate architecture rather than a scatter of related posts.

Retained since, so the design keeps pace with the content instead of ageing the moment it ships.

lamplitlondon.com

Build

London Guide UK

A full WordPress site build for a London listings and guide site, taken from an empty install to a live, structured directory.

The work covered the theme and templates, the plugin stack chosen deliberately rather than piled on, and the configuration that keeps a directory fast as it grows. Security hardened at the login and the admin, because a public facing WordPress site attracts traffic it did not ask for from day one.

Built so an editor can add listings without breaking the layout or the SEO, and so the next developer inherits a clean stack rather than a mess of overlapping plugins.

londonguideuk.co.uk

Ongoing

SEO and competitor research

Continuous competitor, keyword, backlink and SERP analysis, feeding a prioritised roadmap rather than a report that sits unread.

Book me

Got something that looks like one of these?

Send the URL and the symptom. If it is a problem we have seen before we will tell you straight away, and if it is not, we will tell you that too.

Reply within one working day No obligation Your details stay with us

Takes about 60 seconds. No newsletter and no CRM sequence. Your details are used to reply to this enquiry and nothing else. See the privacy notice.