# Simon Hearne | all posts and talks (full text) Source: https://simonhearne.com/ · Index: https://simonhearne.com/llms.txt · Author: https://simonhearne.com/about/ ============================================================================== # Vector Search, Visualised: Milvus Meetup Paris > A visual crash course in vector search, built around an ecommerce dress-finder: exact search, ANN indexes, quantisation, and the filters that quietly wreck your recall. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-10-07 Canonical: https://simonhearne.com/2026/vector-search-visualised-paris/ Tags: VectorDB, Zilliz, Milvus, talk, video, meetup Video: https://www.youtube.com/watch?v=rLT1hzIU--Y Slides: https://talks.simonhearne.com/2026-10-milvus-meetup/ --- ## About the talk This is a refined version of my [Vector Search, Visualised](/2026/visualising-vector-search/) talk, rebuilt around a single ecommerce example: finding the right dress. A shopper searches for a Maison Lune dress for a summer wedding in Provence. Keyword search finds the brand but misses the occasion, vector search finds the occasion but blurs the brand, and hybrid search finds both. From there, the talk follows that dress-finder from zero to production: how embeddings capture meaning, why exact search doesn't scale, what ANN indexes and quantisation trade away, and why a filter as ordinary as "size 38, under €150" can silently break your results. Vector search has no `EXPLAIN`, so the talk closes with how to measure what you can't see, from index recall all the way up to conversion and revenue per search. ## What's covered - **Why vector search exists.** Keyword matches tokens, vectors match meaning. How models turn products into numbers, and what "similar" really means for a catalogue. - **Exact vs. approximate search.** Comparing a query to every product doesn't scale. ANN trades less than 10% recall for search that's over 100× faster and cheaper, framed as a triangle of speed, accuracy and cost. - **ANN indexes, visualised.** HNSW navigates a graph, IVF partitions the space, DiskANN reaches past RAM. - **Making vectors smaller.** Scalar, product and RaBitQ quantisation, PCA and Matryoshka embeddings, and refinement to buy precision back. Plus AUTOINDEX for when you'd rather not tune eight build parameters. - **Filters and silent failure.** Why harder filters destroy more of the graph, three ways out depending on selectivity, and how to catch the missing best match before your customers do. - **Measure what you can't see.** Golden queries scored against exact search on every deploy, and a layered metrics stack: index recall, relevance (NDCG, MRR), behaviour (CTR, zero-result searches) and business outcomes (conversion, return rate). - **Is the index worth it?** Break-even benchmarks for Q&A, code search and agent memory, and where to run it: Milvus Lite, Standalone or Distributed, or Zilliz Cloud. The takeaway: **spend recall on purpose.** Check your model's ceiling, pick an index, shrink, buy back, filter wisely, and measure. Presented at the [Milvus Meetup Paris](https://luma.com/bfo1andh), hosted by Criteo, on 7 October 2026, alongside talks from Criteo on distributed vector search and Gorgias on RAG design patterns for product indexing. ============================================================================== # Haystack Europe 2026: the bleeding edge of retrieval engineering > Agents as search consumers, hybrid retrieval as the default, and how to evaluate search systems - a summary of my takeaways from Haystack Europe 2026. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-09-29 Canonical: https://simonhearne.com/2026/haystack-europe-2026/ Tags: search, VectorDB, Milvus, video, conference Video: https://www.youtube.com/watch?v=Ipqnh4upvrI --- [Haystack Europe](https://haystackconf.com/) took place in Berlin on 15 & 16 September as part of [Berlin Search Week](https://berlinsearchweek.com/): two days, single track, about 120 people who build search for a living. About half of the audience said they run an LLM in production! The sessions broadly shared four underlying themes: - **Agents are a first-class search consumer.** Retrieval stacks are being rebuilt to serve two very different query distributions: human users and agents writing long, precise, operator-rich queries. - **Hybrid search is the default.** Netflix, S&P Global, Leroy Merlin and Coveo all described dense plus sparse retrieval fused with reciprocal rank fusion (RRF) in production. - **Evaluation is key, and difficult.** A lot of the agenda was dedicated to the difficulty in proving that (& how) a retrieval system works. - **New embedding models.** Two novel embedding models were presented - one to embed hierarchy and another that has deterministic dimensions to enable query-time composability. I've written up my notes from the conference below (unfortunately I couldn't attend all of them). All talk recordings are in the [Haystack Europe 2026 playlist](https://www.youtube.com/playlist?list=PLM1qgHLmgOk0) and the full schedule is on the [Haystack website](https://haystackconf.com/schedule/). ## Agents as search consumers ### The unreasonable effectiveness of BM25 for agentic search [Event page](https://haystackconf.com/session/the-unreasonable-effectiveness-of-bm25-for-agentic-search/) · [Recording](https://www.youtube.com/watch?v=ZnmtJKFonLk) *[Jo Kristian Bergum](https://www.linkedin.com/in/jo-bergum/), [Hornet.dev](https://hornet.dev/)* ![bar chart comparing query length: human queries median 2, p99 8 terms; agent queries median 10, p90 17 terms](https://simonhearne.com/images/haystack-europe-2026/bm25_agents_hornet.png) *The median agent query is longer than 99% of human queries (Hornet)* I was nodding along throughout this talk. Jo's first point was that BM25's weak reputation in benchmarks mostly comes from untuned default parameters, not the algorithm. Also that agents are better searchers than people: they know entity names, dates and jargon, and they happily write long, operator-rich queries which are perfect for lexical matching. He also showed agents working over retrieved documents as a workspace or file system, pulling in detail progressively rather than stuffing everything into context. On [BrowseComp-Plus](https://arxiv.org/abs/2508.06600), GPT-5 with a BM25 retriever scores 55.9% accuracy; swap in Qwen3-Embedding-8B and it reaches 70.1% with fewer search calls. Jo showed that this benchmark is flawed - and that a tuned BM25 algorithm can achieve 95.7% answer-presence. Better retrieval is always relevant, and fewer calls is also a cost and latency win. So basically - test it on your systems, don't be afraid to change the defaults, and choose the approach that best balances accuracy and performance (like all search implementations!). I've added this to my (growing) list of experiments to run - if you give an agent access to lexical and dense search plus weighted RRF, which gives the best answer in the fewest turns? I avoided the temptation to try to do this in the evening before my talk the next day! ### The RAG Cost Curve: When an index beats live search [Event page](https://haystackconf.com/session/the-rag-cost-curve-when-an-index-beats-live-search/) · [Slides](https://talks.simonhearne.com/rag-cost-curve/) · [Recording](https://www.youtube.com/watch?v=Ipqnh4upvrI) *[Simon Hearne](https://www.linkedin.com/in/simonhearne/), Zilliz - that's me ([full write-up](/2026/rag-cost-curve/))* ![chart of cost per correct answer for no retrieval, agentic and indexed retrieval on known and unseen code](https://simonhearne.com/images/haystack-europe-2026/rag_cost_curve.png) *Cost per correct answer: indexes win on code the model has never seen* Mine was the only talk that put a price on the index versus live-search decision, which surprised me given how much of the agenda was about agents. The argument: "RAG or agentic search" is a false binary. It's a cost model, decided by corpus size, update frequency and query volume. I grounded it in two open-source systems, [memsearch](https://github.com/zilliztech/memsearch) (agent memory over Markdown) and [claude-context](https://github.com/zilliztech/claude-context) (semantic code search), and in benchmarks of what compression really costs across scalar, product and [RaBitQ](https://arxiv.org/abs/2405.12497) quantisation. During the benchmarks I found a few interesting things: - Agents don't need indexes for code that models have been trained on (e.g. long-running public GitHub repositories) - For private code, the break-even point for an index is in the tens to hundreds of daily queries - If the index is ~free (local, serverless, on an existing box) then the break-even point is on the first query! ### Agent memory that works with your existing search stack [Event page](https://haystackconf.com/session/agent-memory-that-works-with-your-existing-search-stack/) · [Slides](https://plnech.github.io/talks/haystack26/talk.html#1) · [Recording](https://www.youtube.com/watch?v=3aKXXRrCkk8) *[Paul-Louis Nech](https://www.linkedin.com/in/plnech/), Algolia* ![table comparing four memory strategies by seconds, input tokens and facts recalled; memory as a tool uses 9,184 tokens to recall one fact](https://simonhearne.com/images/haystack-europe-2026/agent_memory.png) *Four approaches to agent memory: memory as a tool uses 27× the tokens yet gives less context (Algolia)* Algolia added memory to its [Agent Studio](https://www.algolia.com/products/ai/agent-studio) under one constraint: customers run keyword search, neural search or both, and nobody should need vector-only infrastructure to use it. Paul-Louis showed how far classic search features go for agent memory: tie-breaking ranking, numeric filters, optional word boosting and LLM-generated tags that are boosted at query time. He also covered compressing conversations into multi-query payloads, keyword extraction across 35 languages, and [LongMemEval](https://arxiv.org/abs/2410.10813) results, including why benchmarks are mostly useless. Again - benchmark on your own data and use real customer feedback. Paul-Louis' thesis is different to mine: we agree that agent memory is a retrieval problem, not a transcript-replay problem. Where our talks differed is on what the index should hold. Keyword memory is a measurable improvement on no memory for answer-presence, and on context stuffing for latency & token budgets. One of the most interesting moments in the talk (for me) was that removing stop-words degraded answer-presence - it makes sense on reflection: English stop-words include `my` and `their`, `not` and `is`, `few` and `most`. Stripping those words can totally change the meaning of a sentence. This was the second talk (after [Jo's](#the-unreasonable-effectiveness-of-bm25-for-agentic-search)) that made me want to evaluate BM25 vs. vector search for memory infrastructure. Both claude-context and memsearch use hybrid search so I need to separate the queries to determine answer-presence for each individually compared to the default RRF. ## Hybrid search as the default ### From Tries to Transformers: Scaling Semantic Search [Event page](https://haystackconf.com/session/from-tries-to-transformers-scaling-semantic-search/) · [Slides (PDF)](https://pretalx.com/media/haystackeu26/submissions/7F3XA8/resources/From_Tries_to_gXhR9ny.pdf) · [Recording](https://www.youtube.com/watch?v=dW2vP_Jl7MM) *[Ivan Provalov](https://www.linkedin.com/in/provalov/), Netflix* ![diagram of a five-step pipeline: query logs, human annotations, LLM-as-judge, quality gate, train and deploy](https://simonhearne.com/images/haystack-europe-2026/netflix.png) *The data engine behind Netflix's query understanding model* Ivan shared some great statistics from Netflix that were new (and surprising) to me: only 16% of Netflix discovery starts with search, and the average query is three characters long (typing on TVs is hard). At that length there are no semantics to embed: a user typing `wes` could be looking for Wes Anderson, West Side Story or Westerns. Netflix's answer is to stop picking one interpretation. They generate every plausible facet mapping, retrieve for each, and merge the lists with reciprocal rank fusion (plus personalisation), with a "did you mean" helper on top. The stack has moved from trie lookups to a [Joint-BERT](https://arxiv.org/abs/1902.10909) intent model and a hybrid lexical plus embedding engine, running at 25k QPS (!). Ivan also shared Netflix's open source query testing framework [Netflix/q](https://github.com/Netflix/q), which he demoed in a [lightning talk](https://www.youtube.com/watch?v=HJJfMubvEcE). Short-query ambiguity is a query understanding problem (something I looked at for [typeahead search](/2026/fast-typeahead-milvus/)). Fusion and ranking based on inferred intent is a good solution to a complex UX problem. I'd love to learn how Netflix use customer feedback (CTR, re-search rate etc.) and metrics like MRR to measure and refine their search system. ### Query Understanding with Late Interaction & Wormhole Vectors [Event page](https://haystackconf.com/session/query-understanding-with-late-interaction-wormhole-vectors/) · [Recording](https://www.youtube.com/watch?v=XeA27uhpY_4) *[Trey Grainger](https://www.linkedin.com/in/treygrainger/), [Searchkernel](https://searchkernel.com/)* ![slide showing wormhole vectors connecting sparse lexical, dense semantic and behavioral vector spaces](https://simonhearne.com/images/haystack-europe-2026/wormhole_vectors.png) *Wormhole vectors traverse between sparse, dense and behavioural vector spaces* Trey's opening point was that most teams lean on ranking maths and hope it compensates for never really understanding the query. The talk was then a toolkit for fixing that upstream. The headline idea is wormhole vectors. Standard hybrid search runs a lexical query and a dense query in parallel, then fuses the lists. A wormhole instead *travels* between spaces: run the lexical query, pool the dense vectors of the documents it matched (synthesising a new, pool-averaged vector), and search the dense space from that point. It also works in reverse, from dense results back into sparse terms via a [semantic knowledge graph](https://arxiv.org/abs/1609.00464). Around it he covered [late interaction](https://arxiv.org/abs/2004.12832) models and collaborative filtering for learning latent features from user behaviour. Trey reports benchmarks beating conventional hybrid search (Wormhole precision@10 of 0.862 vs RRF precision@10 of 0.795). If that holds up broadly, it changes what a hybrid search API should expose: not just two queries and a fusion weight, but result-set pooling as a first-class operation. Another thing to add to your benchmarks and evaluations! As both BM25 and dense search have got cheaper and faster, this concept is realistic in production with low double digit millisecond latency budgets. I would like to test it vs RRF with oversampling and semantic re-ranking. ### Sparse encoders: bridging products and knowledge graphs [Event page](https://haystackconf.com/session/sparse-encoders-bridging-products-and-knowledge-graphs/) · [Slides (PDF)](https://pretalx.com/media/haystackeu26/submissions/MN9CDU/resources/Haystack_EU_2_cZZdGOm.pdf) · [Recording](https://www.youtube.com/watch?v=fwW0ujG_c74) *[François Gaillard](https://www.linkedin.com/in/fran%C3%A7ois-gaillard-b2874988/), ADEO (Leroy Merlin, Bricoman)* ![sparse search for 'drill' showing weighted knowledge-graph concepts and top three matching drill products](https://simonhearne.com/images/haystack-europe-2026/sparse_encoders.png) *A readable sparse vector: knowledge-graph concepts and weights for the query 'drill'* ADEO runs home-improvement stores in Europe such as [Leroy Merlin](https://www.leroymerlin.fr/). ADEO's products have complex hierarchy and concepts (building materials vs. tools etc), so they need every product and query mapped onto its knowledge graph of concepts. Millions of product-concept pairs were poorly aligned, and a meaningful share of queries (18M / 21%) did not map correctly. Dense embeddings didn't help: off-the-shelf models don't encode the same concepts, and the results couldn't be explained when a business rule misfired. This lack of 'explainability' is an issue I have encountered many times before, especially in e-commerce and legal document search scenarios. François presented their solution to this challenge: a custom [SPLADE](https://arxiv.org/abs/2107.05720)-style sparse encoder which injects knowledge-graph concepts straight into the model vocabulary as tokens. Every sparse vector is then expressed in the catalogue's own vocabulary. A generative [T5 expansion](https://github.com/castorini/docTTTTTquery) approach lost on inference time (600 ms against 60 ms), but latency was reduced by adding [FLOPS-loss](https://arxiv.org/abs/2004.05665) and tuning the weights to push sparsity past 99.9%, without losing semantic depth. This is the most practical case for learned sparse retrieval I've seen. You get semantic matching with concept understanding, a vector you can actually read and debug (it's just a list of terms), and it can be supported by any search engine that can index sparse vectors (such as [Milvus](https://milvus.io/docs/sparse_vector.md)). ### SPGE Enhanced Search: relevancy engine for Agentic RAG [Event page](https://haystackconf.com/session/spge-enhanced-search-relevancy-engine-for-agentic-rag/) · [Slides (PDF)](https://pretalx.com/media/haystackeu26/submissions/7FLXGJ/resources/SPGE_Enhanced_4ejVhQL.pdf) · [Recording](https://www.youtube.com/watch?v=ydCBqb3VEFY) *[Tomasz Florin](https://www.linkedin.com/in/tomasz-florin-93187553/) & [Artur Barczyński](https://www.linkedin.com/in/arturbarczynski/), S&P Global* ![diagram of one retrieval layer serving web apps, APIs, copilots and AI agents across relevance, experience, discoverability and AI](https://simonhearne.com/images/haystack-europe-2026/spge.png) *One retrieval layer serving web apps, APIs, copilots and agents (S&P Global)* S&P Global runs one hybrid relevancy platform for two very different consumers: the search box in its core enterprise product, and the retrieval layer behind agentic RAG workloads. Lexical and semantic retrieval are fused with RRF, and business-driven relevance rules shape the final ranking. Tomasz and Artur did a good job of calling out the trade-offs in deploying a single retrieval stack. Complex business data needs precision and explainability as much as recall, and agents and analysts want different things from the same index. This is a hint at where large / enterprise use cases are heading: one retrieval platform, several consumers, with relevance rules the business can still control. ## New embedding models ### Hyperbolic embedding models for visual search [Event page](https://haystackconf.com/session/hyperbolic-embedding-models-for-visual-search/) · [Recording](https://www.youtube.com/watch?v=qD-vNtcJpTc) · [Paper](https://arxiv.org/abs/2608.29313) *[Matin Mahmood](https://www.linkedin.com/in/matin-mahmood/), [hyper³labs](https://hyper3labs.com/)* ![architecture diagram of an image-text model mapping captions and image regions into a hyperbolic Lorentz space](https://simonhearne.com/images/haystack-europe-2026/hyperbolic.png) *Hyper3-CLIP embeds image regions and caption phrases in hyperbolic space* Most image search systems store hierarchy outside the vectors, in tags or a knowledge graph. hyper³labs instead trained an open image-text model ([Hyper3-CLIP](https://huggingface.co/hyper3labs/hyper3-clip-v1)) in [hyperbolic space](https://arxiv.org/abs/1705.08039), where distance from the origin encodes specificity: for example "animal" sits near the centre, and "border collie" near the edge. The hierarchy lives in the geometry, so a single nearest-neighbour query can respect it. The weights are open, and the results show good gains over the industry-standard [CLIP](https://arxiv.org/abs/2103.00020) multimodal embedding model. I found the concept intriguing and I can see lots of applications for it - from the obvious e-commerce category search to interesting use cases like security incidents or fraud cases, where specificity could encode the maturity or depth of an attack / case. I was so impressed with the concept that I've built it out on Milvus and published a four part blog series: 1. [Your Vector Database Already Speaks Hyperbolic](https://milvus.io/blog/your-vector-database-already-speaks-hyperbolic.md): what hyperbolic embeddings are, and how ordinary inner-product search can rank them exactly by flipping one sign 2. [Hyperbolic Embeddings in Milvus](https://milvus.io/blog/hyperbolic-embeddings-in-milvus.md): running hyperbolic search on Milvus and measuring recall across index types on ImageNet 3. [Hyperbolic Search on Zilliz Cloud](https://milvus.io/blog/hyperbolic-search-on-zilliz-cloud.md): recall, capacity and latency for 1.28M ImageNet embeddings on a managed cluster, compared with a standard CLIP index 4. [What Hyperbolic Geometry Does to Ranking](https://milvus.io/blog/what-hyperbolic-geometry-does-to-ranking.md): where Hyper3-CLIP beats CLIP on ranking, and where it's not the right option ### Introducing per-document embedding fine-tuning [Event page](https://haystackconf.com/session/introducing-per-document-embedding-fine-tuning/) · [Recording](https://www.youtube.com/watch?v=4ERYs2F9B4A) *[Philippe Bouzaglou](https://www.linkedin.com/in/philippe-bouzaglou-77227811b), Vectra* ![product search demo with sliders adjusting blue and red stone features on an earring embedding](https://simonhearne.com/images/haystack-europe-2026/composable_embeddings.png) *Adjusting individual features of a product embedding at query time (Vectra)* Embeddings are usually frozen at index time: they hold whatever the model could infer from the input and changing them means fine-tuning the model and re-embedding the whole collection (which isn't cheap!). Vectra's e-commerce model is novel - it has *deterministic dimensions* for attributes such as colour or material, so you can adjust an individual document's embedding at query time. Nothing changes at the index layer - the stored vector stays as it is and the adjusted copy is used as the query, so there's no need to retrain the model or re-generate embeddings. My favourite demo was a product embedded from its photo, then enriched at query time with facts the photo can't show (organic cotton, a hippie look, festival wear) to find similar products. The same mechanism can tune a search in real time to a user's preferences. If composable embeddings catch on, an embeddinf doesn't have to be a static snapshot of a document - the index stays the same and all of the flexibility sits in how you build the query vector. That unlocks a huge potential in personalisation, query refinement and operational cost reduction. I'm watching this closely! ## Evaluating search systems ### Beyond LLM Judges: Deep Evaluation for Conversational Search [Event page](https://haystackconf.com/session/beyond-llm-judges-deep-evaluation-for-conversational-search/) · [Slides (PDF)](https://pretalx.com/media/haystackeu26/submissions/JDZWMV/resources/Beyond_LLM_Ju_eXkLm4A.pdf) · [Recording](https://www.youtube.com/watch?v=1E14GMCjFTQ) *[Ruchi Juneja](https://www.linkedin.com/in/ruchi-juneja/), [MediaMarktSaturn](https://www.mediamarktsaturn.com/)* ![slide defining the Conversational Search Reliability Score with Excellent, Good, Fair and Poor bands](https://simonhearne.com/images/haystack-europe-2026/conversational_search.png) *MediaMarktSaturn's Conversational Search Reliability Score* MediaMarktSaturn's conversational shopping assistant had just gone into an A/B test before the conference. Ruchi walked through why scoring only the final answer with an LLM judge wasn't enough. The framework they presented evaluates each layer separately: whether the agent extracted the right search terms, whether its facets matched the catalogue, and how often it hit zero results. The most interesting finding (for me) was that implicit filters hurt. If a customer mentioned "256GB", the agent turned it into a hard filter and narrowed the results - even though that shopper might have happily bought the 128GB or 512GB model. I expect this to come up a lot in agentic retrieval: an agent that follows the query too literally will over-constrain it. Ruchi also described how they distilled their evaluation into a single score graded from Poor (< 50) to Excellent (≥ 90), so they could track progress and report to leadership. I love a simple metric like this. Evaluation is still largely human-graded on recall, accuracy and reliability though, so I'm curious whether the score correlates with business metrics like conversion rate or abandonment. That wasn't covered in the talk. Ruchi's closing line was: "a good evaluation doesn't just tell you how good the system is, it tells you what to fix next." ### Teaching your shopping assistant to learn from its mistakes [Event page](https://haystackconf.com/session/teaching-your-shopping-assistant-to-learn-from-its-mistakes/) · [Slides (PDF)](https://pretalx.com/media/haystackeu26/submissions/R8ETJG/resources/HaystackEU26__JUFr2In.pdf) · [Recording](https://www.youtube.com/watch?v=iRytltsoC4E) *[Jens Kürsten](https://www.linkedin.com/in/jenskuersten/), [OTTO](https://www.otto.de/)* ![slide showing session traces, shop interactions and conversations merged into a conversation timeline, beside the OTTO assistant app and the collect, judge, cluster, review, act loop](https://simonhearne.com/images/haystack-europe-2026/otto_assistant.png) *Turning session traces into signals: the first step of OTTO's weekly loop* This was a great companion to [Ruchi's talk](#beyond-llm-judges-deep-evaluation-for-conversational-search). When OTTO launched its product discovery assistant, failures were silent: shoppers just gave up. Jens described the weekly loop they now run over tens of thousands of production sessions: 1. **Collect** sessions and merge in the funnel events the customer actually saw. 2. **Judge** each one with an LLM anchored to those events. 3. **Cluster** failures into buckets with a single root cause each. 4. **Review** clusters against real dialogues to weed out LLM hallucinations. 5. **Act** on retrieval, prompt engineering or guardrails, then confirm in the next run. The failures OTTO surfaced were interesting (and actionable): over-constrained queries returning zero results (the same problem MediaMarktSaturn saw), gaps in catalogue attributes, and internal users intentionally pushing the guardrails. My key takeaway: use LLMs to generate hypotheses and to do the first pass of classification, but rely on real customer interactions and human review to improve accuracy in production. ## What I'm taking away Getting vectors into an index is a given, the hard problems have moved up the stack: understanding the query, serving agents and humans from one retrieval layer, and proving that the answer was right. What surprised me was that nobody discussed cost (except me). Nothing is free, and I suspect token and compute costs will become a more important factor as these solutions scale. I also came away with a (long) list of experiments to run: BM25 vs. dense retrieval for agent memory, wormhole vectors vs. RRF with re-ranking, and hyperbolic embeddings on Milvus. I'll write them up as I go! ============================================================================== # Fast typeahead search with Milvus > Milvus can serve typeahead search recommendations in about 4ms, but only one or two of the obvious approaches get the answer right. I tested 36 query shapes on Milvus 3.0 so you don't have to. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-09-28 Canonical: https://simonhearne.com/2026/fast-typeahead-milvus/ Tags: Milvus, Zilliz, VectorDB, search --- Website visitors and application owners alike expect near-instant feedback, especially when typing in a search bar and receiving recommendations. Type "iph" into a big retailer's search box and you'll see "iphone 17", "iphone case" and "iphone charger" before you've lifted your finger. The suggestions tend to be the popular ones, and they arrive between keystrokes. If they don't, the user has typed another character and your response is already stale. A team I work with runs typeahead on a dedicated search engine today, alongside Milvus for vector search. Their target is under 50ms from keystroke to suggestions. They asked a fair question: can Milvus do the typeahead too, and save them running a second engine? Milvus is best known as a vector database, but it also has scalar indexes, text analysers, BM25 and, as of 3.0, `ORDER BY` in plain queries. That gives you several plausible ways to build typeahead. I tested them all on Milvus 3.0.2 running in Zilliz Cloud to find out which ones return the right answer and at what speed. **TL;DR:** keep a lowercased copy of each suggestion in a `VARCHAR` field with an `NGRAM` index. Query it with `LIKE "prefix%"` and sort by a popularity field. That returned exactly the right top 10 every time, with a p95 of about **4ms** measured at the client. That's under 1ms slower than fetching a single row by primary key, and a single 1 CU performance-optimised cluster held that up to about 1,600 requests per second. ## What typeahead actually needs Search engineers will recognise the requirements, but it's worth being explicit because they rule out a lot of options: - **Prefix matching.** "sam" should match "samsung tv". Most full-text machinery matches whole tokens, so "sam" matches nothing. - **Word-start matching (sometimes).** "tv" matching "samsung tv" is useful. Whether it's required depends on the product. - **Ranking by something other than relevance.** Every candidate matches the prefix equally well. What decides the top 10 is popularity: query frequency, clicks or sales. - **Case and accent insensitivity.** People type "IPH", "iph" and "Iph". - **Speed.** The budget is tens of milliseconds end to end, including network and browser time. Dedicated engines solve this with completion suggesters or edge n-gram analysers. Milvus doesn't have a completion suggester so the question becomes which of Milvus's other building blocks can stand in. ## The candidates I gave each approach its field and index in a single collection: | Approach | Field and index | Query | |---|---|---| | Unindexed string | `VARCHAR`, no index | `LIKE "p%"` or `LIKE "%p%"` | | Inverted index | `VARCHAR` + `INVERTED` | `LIKE "p%"` | | Trie | `VARCHAR` + `TRIE` | `LIKE "p%"` | | N-gram index | `VARCHAR` + `NGRAM` (2 to 3 characters) | `LIKE "p%"`, or `LIKE "p%" or LIKE "% p%"` for word starts | | Analysed text | `VARCHAR` with a standard or custom analyser | `text_match`, `phrase_match`, `text_match_fuzzy` | | BM25 | sparse vector generated from the analysed text | full-text `search()` | | Client-side edge n-grams | each word expanded at write time ("sam" → "s sa sam") with a whitespace + lowercase analyser | `text_match` | | Prefix array | `ARRAY` of the same edge n-grams + `INVERTED` | `array_contains_all` | | Vector ranking | `[log(popularity), 0]` as a 2-d vector | ANN search with a `LIKE "p%"` filter | Filter-only approaches used `query()` with `order_by_fields=["popularity:desc"]` and `limit=10`, so no vector search is involved at all. ## Correctness first There's no point benchmarking a query that returns the wrong rows, so the first pass was a capability test. I ran ten deliberately awkward probe terms ("iPhone 15 Pro Max", "Sony WH-1000XM5", "Crème brûlée torch", "耳机 蓝牙" and so on) against 36 combinations of approach and query. Then I compared the results with ground truth computed in Python. The analysed-text approaches fell at the first hurdle. `text_match`, `phrase_match` and BM25 tokenise the stored text into whole words, so "iph" never matches "iphone". They only returned the right rows when the typed characters happened to be a complete word, such as "usb" or "15". Fuzzy matching doesn't rescue this: it corrects a typo within a whole token, it doesn't complete a partial one. Everything that matches on characters rather than tokens passed: every `LIKE "p%"` variant, the client-side edge n-grams and the prefix array. Two details matter: - **`LIKE` is case-sensitive.** Store a normalised copy of the text (lowercased, Unicode-normalised, whitespace collapsed) and apply the same normalisation to what the user types. Among the approaches that passed, only the edge n-gram field with a `lowercase` filter in its analyser was case-insensitive without client-side normalisation. - **Typo tolerance is weak at typeahead lengths.** Fuzzy matching on edge n-grams recovered swapped characters, but at 3 characters it also returned plenty of noise. It only became reliable from about 5 characters. ## Latency For the benchmark I loaded 10,000 realistic e-commerce search terms and a simulated 'popularity' numeric field: - **Cluster:** Milvus 3.0.2 on a single 1 CU Zilliz Cloud cluster. - **Queries:** prefixes of 3 to 6 characters, sampled by popularity so common prefixes come up often, as they do in real traffic. - **Load:** a steady 20 requests per second, open loop, so queueing shows up as latency. - **Client:** an EC2 instance in the same AWS region as the cluster, with a TCP round trip of about 1.6ms. - **Duration:** 60 seconds per configuration after a warm-up, 180,000 requests in total. For a floor, I measured the minimum round-trip latency by fetching a single row by primary key at the same rate: **3.1ms at p50 and 3.5ms at p95**.
Loading chart...
Nearly every approach lands within a millisecond of that floor, whichever index it uses and however many characters are typed. The `NGRAM` field with `LIKE "p%"` had a p95 of **3.7 to 4.0ms**, and the edge n-gram field was **3.8 to 3.9ms**. BM25 search took about half a millisecond longer. Vector ranking with a `LIKE` filter was the slowest at 4.7 to 5.8ms, still far inside the budget. There were no errors across all 180,000 requests. ## Speed is easy, the right top 10 is harder If latency were the only criterion, you could pick almost anything. So I also checked every response against the expected top 10: every term matching the prefix, sorted by popularity. This is where the approaches separate.
Loading chart...
- **`LIKE "p%"` with `ORDER BY`** returned exactly the right top 10, in the right order, on 100% of requests, on every index type. - **Word-start matching** was also 100% correct with the `NGRAM` field (`LIKE "p%" or LIKE "% p%"`), the edge n-gram field and the prefix array. The edge n-grams only do word starts, though. Typing "tv" returns anything with a word starting "tv", so against a strict whole-string expectation they scored lower. - **Infix `LIKE "%p%"`** matches the middle of words ("pho" finds "headphones"). That's usually not what someone typing expects. - **BM25** ranks by term statistics, not popularity, so it almost never produced the expected top 10. Fetching 100 results and re-sorting by popularity only got about half of them right. - **Vector ranking** was right 89 to 96% of the time. The search is approximate, so neighbouring popularities occasionally swap places. ## What I'd recommend For most catalogues, one normalised `VARCHAR` field with an `NGRAM` index covers both whole-string and word-start completion. Create the collection like this: ```python from pymilvus import MilvusClient, DataType client = MilvusClient(uri=ZILLIZ_URI, token=ZILLIZ_TOKEN) schema = client.create_schema(auto_id=False, enable_dynamic_field=False) schema.add_field("id", DataType.INT64, is_primary=True) schema.add_field("suggestion", DataType.VARCHAR, max_length=512) # shown to the user schema.add_field("suggest_norm", DataType.VARCHAR, max_length=512) # normalised copy for matching schema.add_field("popularity", DataType.INT64) # what decides the top 10 schema.add_field("placeholder_vec", DataType.FLOAT_VECTOR, dim=2, nullable=True) # Milvus requires one index_params = client.prepare_index_params() index_params.add_index(field_name="suggest_norm", index_type="NGRAM", min_gram=2, max_gram=3) index_params.add_index(field_name="popularity", index_type="STL_SORT") index_params.add_index(field_name="placeholder_vec", index_type="AUTOINDEX", metric_type="IP") client.create_collection("typeahead", schema=schema, index_params=index_params) ``` Then query it on each keystroke, after normalising the input the same way as the stored text: ```python hits = client.query( "typeahead", filter=f'suggest_norm like "{prefix}%"', # word starts too: f'suggest_norm like "{prefix}%" or suggest_norm like "% {prefix}%"' limit=10, order_by_fields=["popularity:desc"], output_fields=["suggestion"], consistency_level="Bounded", ) ``` Escape `%`, `_` and `\` in the user's input before building the filter, as you would for any `LIKE`. Also note that the `NGRAM` index is only used when the typed text is at least `min_gram` characters long. Shorter input falls back to a full scan, so either start querying at 2 or 3 characters or set `min_gram` to match. If you need matching to ignore case on the server, or want every typed word to match ("sony wh" → "sony wh-1000xm5"), use the client-side edge n-gram field instead. Its analyser is `{"tokenizer": "whitespace", "filter": ["lowercase"]}`, and you AND one `text_match` per typed word. You pay for it with a larger field and some extra work at write time. **Ties need a tiebreaker.** Popularity scores repeat, especially in the long tail, and Milvus breaks ties in no particular order. In an earlier 100,000-term run, 0.7% of responses listed tied suggestions in a different order from my ground truth. The results were still correct, just not stable between calls. Add a second sort key if the order must be stable: `order_by_fields=["popularity:desc", "id:asc"]`. **No popularity signal?** You still need an `ORDER BY`. Without one, Milvus returns matches in storage order, which is arbitrary and can change after compaction. Sorting by an integer length field and then alphabetically (`order_by_fields=["term_len:asc", "suggest_norm:asc"]`) puts "iphone" ahead of "iphone 15 pro max case", which is a reasonable default. Deriving popularity from search logs is better still. ## How far does 1 CU go? A few milliseconds at 20 requests per second says nothing about capacity. So I loaded **100,000 terms** and ramped the recommended query from 100 QPS, adding 100 QPS every minute, up to 2,000 QPS. It ran on the same 1 CU cluster, with 7 load-generating processes on an in-region `c7i.2xlarge`.
Loading chart...
All 1.26 million requests succeeded, and every step sustained its target rate, up to 2,000 QPS. But the shape of the curve matters more than the pass mark: - **Up to about 1,600 QPS, latency is improves with throughput.** p50 sits around 3ms and p99 stays under 5ms. - **From 1,700 QPS the tail climbs.** p99 latency increases to almost 50ms at 2,000 QPS, at this point we're still within budget but the cluster is queueing requests and it's probably time to scale up for the demand. - **The client wasn't the limit.** The test machine CPU peaked at 15% and requests were submitted on schedule to within 1ms at p99. So for this query shape, one CU serves roughly **1,500 QPS with a p99 under 5ms**. That's a lot of typeahead: at a request per keystroke, it's hundreds of people typing at the same moment. ## Caveats - **100,000 rows is still modest.** Correctness doesn't change with scale, but latency and capacity can. `ORDER BY` sorts every match before taking the top 10, so a broad three-character prefix on a catalogue of millions will cost more than it does here. Test at your real size. - **The capacity figure is for one query shape.** The ramp used whole-string prefixes of 3 to 6 characters, weighted towards popular terms. Word-start queries, bigger result sets or concurrent writes would all need further benchmarking. Nothing here needed a vector search, a second engine or an exotic index. If Milvus is already in your stack for vectors, the typeahead can live next to them, in the same collection, answering in a few milliseconds. ## Reproduce I've open sourced the [benchmarking methodology](https://github.com/simonhearne/milvus_typeahead_benchmarks) on GitHub so you can try it yourself. ============================================================================== # The RAG Cost Curve > When does a retrieval index actually beat live search? Measured break-even points, not vendor claims: 334 queries a day on a short agent history, never on code the model already knows, and 24 on code it has never seen. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-09-15 Canonical: https://simonhearne.com/2026/rag-cost-curve/ Tags: VectorDB, Zilliz, Milvus, talk, video, conference Video: https://www.youtube.com/watch?v=Ipqnh4upvrI Slides: https://talks.simonhearne.com/rag-cost-curve/ Link: Reproduce every number: https://github.com/simonhearne/rag-cost-curve --- I assumed that an index would be more efficient than agentic code search, fewer turns and lower token consumption, with break-even on the order of 10s - 100s of searches per day. It turns out not to be so clear cut. ## About the talk "Just use RAG" and "RAG is dead" are both answers to a question nobody asked precisely. An index costs money to build, to store and to keep current. Live search costs money every time you run it. Which one wins is not a matter of taste, it is a crossover point, and it moves depending on what you are searching and how often. This talk puts a number on that crossover for two workloads, conversation memory and code search, and shows the measurements behind it. Every figure comes from a committed CSV, every chart renders from that CSV at build time, and the whole study runs on a laptop with Docker. The honest finding is that the index does not always win. It loses badly in one of the cases I measured, and the talk spends time there rather than skipping past it. ## What's covered - **Defining break-even.** What goes into the cost of an index against the cost of live search, and why "worth it" is meaningless without naming what you are comparing against. - **Quantisation and dimensionality reduction.** Scalar, product and RaBitQ quantisation, PCA and Matryoshka embeddings, what each costs you in recall, and how refinement claws precision back. SQ8 won on its own. - **Does recall even matter?** Recall barely moves answer quality until it falls off a cliff, mostly because most questions never had a choice. Aim for recall@k of about 0.95 and stop optimising. - **Two measured workloads.** Conversation memory, and code search against three repositories: one the model already knows, one it has never seen, and the same repository stuffed into context instead of grepped. - **The cost curves, assembled.** Where each workload crosses over, and where it never does. ## What the numbers said **Live search wins** when the corpus is small and the model already knows it: - A short agent history, at 98k tokens of conversation memory, needs **334 queries a day** before a dedicated index pays for itself. - On `fastapi`, a repository the model already knows, break-even is **infinite at every size**. No query volume repays the index, because grep is cheaper every time. - Hold the prompt cache at 99% rather than the measured rate and the crossover is still **232 queries a day**, so the cache assumption is not doing all the work. **The index wins** as the corpus grows, or when the model has never seen it: - Conversation memory collapses fast: **15 queries a day** at 392k tokens and **10** at 1.2M, against 334 at 98k. - On code the model has never seen, the index is **40% cheaper per correct answer** than grep, and more accurate with it: 39 of 40 correct against 36. - Against stuffing the same repository into context rather than grepping it, the index repays at **12 queries a day**: $0.29 a query re-sent against $0.067 indexed. That last pair is the sharpest thing in the talk. Same repository, same index, same box, same prices. The only thing that changes is what the live arm does, and the answer flips from "no volume repays this" to "twelve queries a day". The question is never "is retrieval worth it", it is "worth it against what". ## Reproduce it Everything in the talk runs on your own machine, given Docker with 16GB of RAM or more: ```bash make setup && source .venv/bin/activate make up # docker compose up -d + Milvus health wait make data-1m # or data-10m jupyter lab # notebooks 01 to 06b in order ``` If you want the tooling rather than the study, [memsearch](https://github.com/zilliztech/memsearch) gives Claude semantic recall over its own conversation history, and [claude-context](https://github.com/zilliztech/claude-context) adds semantic code search to coding agents over MCP. Presented at [Haystack Europe 2026](https://haystackconf.com/speaker/simon-hearne/) in Berlin on 15 September 2026. ============================================================================== # Migrating to Milvus: Tooling, Deployment Choices and Lessons from Real Migrations > How to move from Elasticsearch or OpenSearch to Milvus or Zilliz Cloud: migration tooling, a real end-to-end walkthrough, deployment choices, and lessons from real customer migrations. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-09-02 Updated: 2026-09-08 Canonical: https://simonhearne.com/2026/migrating-to-milvus-talk/ Tags: VectorDB, Zilliz, Milvus, video, talk Video: https://www.youtube.com/watch?v=0xU3Ks2vXIM Slides: https://talks.simonhearne.com/2026-09-milvus-migration/ --- This is the follow-up to [Milvus 3.0: Closing the Search Gap](/2026/milvus_search_gap/). If you are moving self-managed Milvus rather than Elasticsearch, see [Migrating Self-managed Milvus to Managed Milvus](/2026/migrating-milvus/). ## About the webinar Milvus 3.0 expands the range of search workloads teams can run in Milvus, with stronger full-text retrieval plus in-engine sorting, aggregation and faceting. In [the previous webinar](/2026/milvus_search_gap/) I explored these search improvements and compared Elasticsearch and Milvus with live side-by-side queries. In this follow-up session I move from comparison to migration. I show how Zilliz Migration Service helps move data from Elasticsearch or OpenSearch into Milvus or Zilliz Cloud, then walk through a real migration workflow. I also compare Elastic Cloud (ESS) and Zilliz Cloud, discuss deployment options across open-source Milvus, Zilliz Cloud and Zilliz BYOC (Bring Your Own Cloud), and share lessons from customer migrations. If you use Elasticsearch or OpenSearch today and are considering a migration, this is the walkthrough for you. ## What's covered - **The migration path.** The major stages and decisions in an Elasticsearch or OpenSearch migration to Milvus or Zilliz Cloud. - **Migration tooling and a real walkthrough.** How VTS (the open-source migration tool) and Zilliz Migration Service (the managed service built on VTS) move Elasticsearch and OpenSearch data, followed by a real migration workflow end to end. - **Licensing and commercial tradeoffs.** Licensing models and the commercial considerations behind Elastic Cloud (ESS) and Zilliz Cloud. - **Deployment choices.** Open-source Milvus, Zilliz Cloud and Zilliz BYOC compared across operating model, control and security needs. - **Customer migration stories.** Why real teams moved, how they approached the migration, and what they learned along the way. - **Live AMA.** Around 20 minutes of migration, architecture and evaluation questions. Presented as a [Zilliz webinar](https://zilliz.com/event/migrating-from-elasticsearch-opensearch-to-milvus) on 2 September 2026. ============================================================================== # Milvus 3.0: Closing the Search Gap > Milvus 3.0 closes the gap between vector search and full-text search: one engine for both. A customer-backed comparison of Milvus 3.0 and Elasticsearch 9.14, with live side-by-side queries you can reproduce yourself. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-08-19 Canonical: https://simonhearne.com/2026/milvus_search_gap/ Tags: VectorDB, Zilliz, Milvus, video, talk Video: https://www.youtube.com/watch?v=XxcA1NSQnCo Slides: https://talks.simonhearne.com/2026-08-milvus-search-gap/ Link: Test it yourself!: https://github.com/simonhearne/milvus_es_lab --- The follow-up webinar, [Migrating to Milvus](/2026/migrating-to-milvus-talk/), moves from comparison to migration: tooling, deployment choices and lessons from real migrations. ## About the webinar Milvus has long been known for vector search and has supported full-text search since Milvus 2.5, allowing teams to combine lexical and semantic retrieval in one engine. With Milvus 3.0, full-text search takes another major leap: - **Compressed BM25 indexes** roughly 3× smaller than those in Milvus 2.6 at comparable recall, reducing memory and bandwidth pressure. - **SINDI**, a new sparse-retrieval algorithm that delivered roughly 5× to 10× the QPS of MaxScore across four learned-sparse benchmarks, and extends the optimised retrieval path to native BM25. - **Sorting, aggregation and faceting** inside the engine. With stronger full-text retrieval added to Milvus's established vector-search foundation, teams can reconsider whether AI retrieval still requires a separate full-text system, along with the infrastructure, pipelines and result-fusion logic that come with it. This talk is a customer-backed comparison of Elasticsearch and Milvus, including live side-by-side queries against Milvus 3.0 and Elasticsearch 9.14. Query semantics, results and performance are all reproducible with the open-source lab linked above. ## What's covered - **Milvus 3.0 full-text search.** BM25, sparse retrieval, index efficiency, server-side sorting, aggregation and faceted search, with a brief look at planned fuzzy matching, multi-phase reranking and JSON-field aggregations. - **Elasticsearch vs. Milvus.** Search capabilities, performance and operational tradeoffs across the two systems. - **Live query demo.** The same queries in Elasticsearch and Milvus, with results and latency visible on screen. - **Hybrid search and ranking.** How full-text and vector results can be combined through ranking and fusion methods such as RRF. - **Production customer evidence.** How real teams improve retrieval quality, support growing workloads and simplify search operations. - **Live AMA.** A 20-minute discussion of search patterns and architecture questions. Presented as a [Zilliz webinar](https://zilliz.com/event/faster-more-powerful-full-text-search-milvus-3-0) on 19 August 2026. ============================================================================== # Migrating Self-managed Milvus to Managed Milvus to gain >99% Performance > At small scale, self-managing a vector database is manageable. Once you're in the tens of millions of embeddings you'll probably want to offload the headache to a managed service. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-07-14 Canonical: https://simonhearne.com/2026/migrating-milvus/ Tags: Milvus, Zilliz, VectorDB --- So you've built an application using Milvus as the vector database, using [Standalone](https://milvus.io/docs/install_standalone-docker.md) or [Distributed](https://milvus.io/docs/install_cluster-milvusoperator.md?tab=helm). You've reached a point where the application is working, customers are using it and the data volume is growing. At some point managing the vector database starts consuming more time, pod failures cause service jitter, you're running out of RAM on the server, or `etcd` becomes a painful bottleneck. At this stage you're probably exploring a managed service to take the operational burden away. The good news is that migrating from Milvus to Zilliz Cloud is straightforward, and the various options are [well documented](https://docs.zilliz.com/docs/migrate-from-milvus). In my case, I had built a simple Wikipedia RAG application: 50M embeddings using Cohere's Embed v3 multi-lingual model at 1,024 dimensions (covering the entire Wikipedia English corpus). Initially I hosted this on my laptop using Milvus Standalone, but the container was unstable and restarts took \~20 minutes - not ideal when you want to show a demo! Query performance was also suffering due to frequent paging (I don't have enough memory on my laptop, so I enabled [mmap](https://milvus.io/docs/mmap.md)). Below is a quick video showing the application in action:
The app works, but it could be faster
Three second query times aren't great, so without the budget for a new laptop, I planned my migration to managed Milvus on Zilliz Cloud. The following is the step-by-step process I followed - there are a number of methods available, but I chose backup / restore for simplicity. ## Create a backup Zilliz provides the [milvus-backup](https://github.com/zilliztech/milvus-backup) utility, and installing it is as easy as `brew install milvus-backup` You then need to create a config file (milvus-backup looks for `backup.yaml` in the current working directory by default). This is a minimal example to create a backup from Standalone running in Docker locally (see the full [yaml options](https://github.com/zilliztech/milvus-backup/blob/main/configs/backup.yaml) on GitHub): ```yml milvus: address: localhost port: 19530 user: "root" password: "Milvus" tlsMode: 0 etcd: endpoints: 127.0.0.1:2379 rootPath: "by-dev" minio: storageType: "local" rootPath: "/../milvus_wikipedia/volumes/milvus/data" backupStorageType: "local" backupRootPath: "/../milvus-backup-test/backup" ``` Then run a quick check: ```bash $ milvus-backup check Milvus version: 2.6.2 Storage: milvus-storage-type: local milvus-bucket: a-bucket milvus-rootpath: /../milvus_wikipedia/volumes/milvus/data backup-storage-type: local backup-bucket: a-bucket backup-rootpath: /../milvus-backup-test/backup Success! ``` And finally create the backup (this will take a little time): ```bash milvus-backup create -n wiki_backup ``` ## Create the target instance on Zilliz Cloud First make sure you have a [Zilliz Cloud](https://cloud.zilliz.com/) account - then create an instance that matches the minimum requirements of your local deployment. For my 50M x 1,024-D embeddings on Tiered-Storage, the [public calculator](https://zilliz.com/pricing#calculator) estimated that I required 2 [Query CU](https://zilliz.com/blog/how-to-choose-the-right-cu-type-and-size): ![screenshot zilliz cloud pricing calculator](https://simonhearne.com/images/migrating-milvus/estimation.png) *Public pricing calculator estimates Query CUs* So I navigated to the cloud console, clicked "\+ Cluster" and used these settings: ![screenshot zilliz cloud cluster creation screen](https://simonhearne.com/images/migrating-milvus/creation.png) *Creating a cluster in Zilliz Cloud* While it is creating, you can retrieve / generate the API key we will need for the next step: ![screenshot zilliz cloud generate api key](https://simonhearne.com/images/migrating-milvus/api_key.png) *Creating / retrieving API keys in Zilliz Cloud* And note the cluster ID of the new instance: ![screenshot zilliz cloud get instance ID](https://simonhearne.com/images/migrating-milvus/connection.png) *Retrieve Cluster ID from Zilliz Cloud* ## Migrate to Zilliz Update your `backup.yaml` to include the cloud key you just generated / retrieved: ```yml cloud: address: https://api.cloud.zilliz.com apikey: ``` Then finally we just need to run one migration command, with the backup name and the target cluster ID passed as arguments: ```bash milvus-backup migrate -n wiki_backup -c ``` In my case it took a few hours to upload the \~120GB backup file to a [Volume](https://docs.zilliz.com/docs/managed-volume) on Zilliz Cloud, then about an hour to create the target cluster from the Volume. You can monitor the migration status in the Zilliz Cloud console under Jobs: ![screenshot zilliz cloud job progress](https://simonhearne.com/images/migrating-milvus/jobs.png) *View job progress in Zilliz Cloud* Once the migration is complete, make sure you load the collection into the cluster! ![screenshot zilliz cloud load collection ](https://simonhearne.com/images/migrating-milvus/load_collection.png) *Load the new collection in the cluster* ## Validation Now you can simply update the application to use the new cluster endpoint and credentials.
The app still works, and now queries are not the bottleneck!
We observe a performance improvement compared to Milvus Standalone - 25ms compared to 3,112ms - that's over a *99% latency reduction*!. This is partly due to the increased compute allocated in the cloud service, as well as the proprietary index engine in Zilliz Cloud - [Cardinal](https://zilliz.com/cardinal) - which can achieve 10x faster queries compared to Milvus OSS. The performance win is huge, but even better I no longer need to worry about the container stopping and I can free up \~20GB RAM on my laptop! ## Other considerations - If your application isn't running 24x7, your vector database doesn't need to either. [Suspend](https://docs.zilliz.com/docs/manage-cluster#suspend) inactive clusters to drop the compute cost to zero when it is not being used. - Tiered-Storage is a great cluster type for low (\<10 QPS) requirements, but there are [other options](https://docs.zilliz.com/docs/cu-types-explained) and you can [migrate](https://docs.zilliz.com/docs/offline-migration) data between them: - **On-Demand** - only uses compute when there are queries being run. This could drop the cost by 99%, depending on the frequency of queries, at a cost of higher cold-start latency. - **Capacity-Optimized** - provides reduced data density compared to Tiered-Storage, but achieves 10x greater throughput and 2-5x faster queries. This would increase the compute cost of my example by about 60%. - **Performance-Optimized** - provides even greater throughput (\>1,000 QPS per replica) and performance (10-100x faster) at the cost of further reduced data density. - Zilliz Cloud supports mounting external volumes from Google Cloud Storage, Amazon S3, Azure Blob Storage etc. So a cleaner approach would be to upload the backup directly to cloud storage and restoring it from there. - If creating / restoring a backup is not possible for any reason, and the Milvus deployment can be made accessible publicly - you can use the [Migrate from Milvus Endpoint](https://docs.zilliz.com/docs/via-endpoint) feature in Zilliz Cloud. - Everything we performed in Zilliz Cloud can be managed via [API](https://docs.zilliz.com/reference/restful) and/or [Terraform](https://docs.zilliz.com/docs/terraform-provider) if click-ops is not your preferred methodology. ============================================================================== # Vector Search, Visualised > Vector search has no EXPLAIN. Through interactive visualisations, this talk shows what embeddings look like in high-dimensional space, what quantisation does to your recall, and how to catch retrieval failures before your agents do. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2026-06-04 Canonical: https://simonhearne.com/2026/visualising-vector-search/ Tags: VectorDB, Zilliz, Milvus, talk, video Video: https://www.youtube.com/watch?v=kY-il0pWjdY Slides: https://talks.simonhearne.com/vector-search-visualised/ --- ## About the talk SQL makes sense. But when it breaks, you reach for `EXPLAIN`. Vector search offers no such comfort. Multi-thousand-dimension embeddings, approximate nearest-neighbour indexes, and quantisation tradeoffs make it hard to know what your system is doing, and harder still to diagnose when results quietly degrade. Through interactive visualisations, this talk shows what embeddings look like in high-dimensional space, what quantisation does to your recall, and how to catch retrieval failures before your agents do. You'll leave with a sharper mental model and a diagnostic toolkit for the production problems hardest to see. ## What's covered - **Why vector search exists.** Keyword search matches tokens, vector search matches meaning. How models turn text, images and faces into numbers, and what "similar" really means. - **Exact vs. approximate search.** Why flat search doesn't scale, the trade-off triangle of speed, accuracy and cost, and how recall and precision measure what you give up. - **ANN indexes, visualised.** IVF partitions the space, HNSW navigates a graph, DiskANN reaches past RAM. Each with its tuning knobs and where it lands on the triangle. - **Quantisation and dimensionality reduction.** Scalar, product and RaBitQ quantisation, PCA and Matryoshka embeddings, what each costs you in recall, and how refinement claws precision back. - **Filters and silent failure.** Why filtering quietly wrecks recall, three ways out by selectivity, and the signals of degradation that your agent will never report. - **The EXPLAIN you don't get.** How to measure what you can't see, and strategies that actually work in production. Presented at [TiDB SCaiLE Europe 2026](https://www.pingcap.com/blog/tidb-scaile-europe-2026/) in Stockholm on 4 June 2026 as "When EXPLAIN Isn't Enough: Visualising Vector Search for Engineering and Product Teams". ============================================================================== # Adding site search to Netlify sites using Elasticsearch > A guide to implementing static site search using 11ty, Netlify and Elasticsearch Author: Simon Hearne (https://simonhearne.com/about/) Published: 2024-05-07 Canonical: https://simonhearne.com/2024/netlify-elasticsearch/ Tags: search, elasticsearch, netlify --- Static site generators (SSGs) such as [11ty](https://www.11ty.dev/) allow us to build super-fast websites with great developer experience. Hosting platforms like [Netlify](https://www.netlify.com/) allow us to rapidly iterate and release directly from GitHub. SSGs are perfect for (mostly) static sites such as blogs (like this one!), corporate sites, documentation sites etc. Search, however, is not static. You can build static-ish site search using solutions such as [Pagefind](https://pagefind.app/) but these solutions have limitations in hosting environments and flexibility. SaaS search solutions like Algolia have their place too, but the [official Algolia x Netlify integration](https://www.netlify.com/integrations/algolia/) simply triggers a web crawl of a site rather than indexing content directly. Again, this limits flexibility and results in a delay between build and updating the search index. As a fan of elasticsearch I thought I would have a go at implementing search on this site using pure 11ty, Netlify and Elastic capabilities: 1. Create a search index at build time with 11ty 2. Update a remote elasticsearch index after build 3. Use an Elastic search application to create a simple search experience 4. Use Elastic Behavioral Analytics to capture search & click data Sounds simple enough! Let's walk through the steps: ## Create a search index at build time There are a number of ways to do this, but I took the route that seemed the most simple to me - using nunjucks. I added a new nunjucks filter and a page template to render blog posts as `ndjson`, which will be important later: ```js /* --- eleventy.js --- */ eleventyConfig.addNunjucksFilter("ndjson", (page)=> { let d = new Date(); return JSON.stringify({ 'url': 'https://simonhearne.com'+page.url, 'tags': page.data.tags, 'published': page.date, 'title': page.data.title, 'excerpt': page.data.excerpt, 'image': page.data.image, 'indexed': d.toISOString() }); }); ``` ```njk {# --- esindex.ndjson.njk --- #} --- permalink: /esindex.ndjson eleventyExcludeFromCollections: true --- {%- for post in collections.posts %} { "index": {"_id": "{{post.url}}"}} {{ post | ndjson | safe }} {%- endfor %} ``` This creates a new file at build time, `esindex.ndjson`, at the root of the published site directory. It should look a little like this: ```json { "index": {"_id": "/2015/web-performance-optimisation-basics/"}} {"url":"https://simonhearne.com/2015/web-performance-optimisation-basics/","tags":["posts","WebPerf"],"published":"2015-03-11T19:54:00.000Z","title":"Web Performance Optimisation Basics","excerpt":"Website performance is critical to user experience. We need rules to make it easier.","image":"./images/speed_hero.jpg","indexed":"2024-05-07T11:47:22.116Z"} ... ``` Note that this `ndjson` file corresponds to the format required for the [elasticsearch `_bulk` API](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html). I've set the `_id` of each document to the path of the page, this is unique in my context and means that further indexing requests to existing documents in elasticsearch will replace the older document. ## Index the data to elasticsearch We can validate the bulk data created above by POSTing it to an elasticsearch instance (assuming you have the relevant variables set): ```sh curl -XPOST -H \"Content-Type: application/x-ndjson\" -H \"Authorization: ApiKey $ELASTIC_APIKEY\" $ELASTIC_HOST/$ELASTIC_INDEX/_bulk --data-binary @_site/esindex.ndjson ``` If that works as expected, we can lazily add it to our build command: ```toml # --- netlify.toml --- # [build] publish = "_site" command = "npm run build && curl -XPOST -H \"Content-Type: application/x-ndjson\" -H \"Authorization: ApiKey $EAK\" $ELASTIC_HOST/$ELASTIC_INDEX/_bulk --data-binary @_site/esindex.ndjson" ``` You will need to [add your variables](https://docs.netlify.com/configure-builds/environment-variables/) to your Netlify config of course! ## Create a search application [Search Applications](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-application-overview.html) allow you to create a public API endpoint against an elasticsearch instance. Our use case is simple but you can do all sorts with search applications! I've set my index name `$ELASTIC_INDEX` to `search-simonhearne` (original I know) so creating my search application (called `simonhearne`) is as simple as a single `PUT` via Kibana dev tools: ```json PUT /_application/search_application/simonhearne { "indices": [ "search-simonhearne" ], "template": { "script": { "source": { "fields": ["title","tags","url","excerpt","published","image"], "query": { "bool": { "should": [ { "multi_match": { "query": "{{query}}", "fields": ["title^4","tags","excerpt","url"], "fuzziness": "auto" } }, { "multi_match": { "query": "{{query}}", "fields": ["title^4","tags","excerpt","url"], "type": "phrase_prefix" } } ] } }, "from": "{{from}}", "size": "{{size}}", "highlight": { "fields": { "title": {}, "excerpt": {} }, "tags_schema" : "styled", "fragment_size": 150, "number_of_fragments": 1, "type": "plain" } }, "params": { "query": "*", "from": 0, "size": 10 } } } } ``` This request is doing quite a lot, defining the query I want the search application to run (against the post `title`, `tags`, `excerpt` and `url`) with relevant weightings, also to run a phase prefix query to provide a bit of query completion logic. I've also enabled [highlighting](https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html) to enable me to show highlights in the search results. We can test this search application with a simple `POST` against the application endpoint: ```json POST _application/search_application/simonhearne/_search { "params": { "query": "velo" } } ``` ## Implementing the search client We need to create an API key for the search application to allow public API requests: ```json POST /_security/api_key { "name": "public api key", "role_descriptors": { "my-restricted-role-descriptor": { "indices": [ { "names": ["simonhearne"], "privileges": ["read"] } ], "restriction": { "workflows": ["search_application_query"] } } } } ``` With the API key, we can now construct a client-side API call: ```js const searchEndpoint = "/_application/search_application/simonhearne/_search" const apiKey = "" const body = { "params": { "query": term } }; let response = await fetch(searchEndpoint, { method: 'POST', mode: 'cors', headers: { 'Authorization': `apiKey ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); ``` And render the results (using a template literal function in this case): ```html const postTemplate = (result,term,position) => `
  • ${(result.hasOwnProperty('highlight') && result.highlight.hasOwnProperty('title')) ? result.highlight.title[0] : result.fields.title}
    ${(result.hasOwnProperty('highlight') && result.highlight.hasOwnProperty('excerpt')) ? result.highlight.excerpt[0] : result.fields.excerpt}
  • `; ``` Note that there is a [search application client library](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-application-client.html) which can do this for you, I just prefer hacking away at HTML and JS manually. ## Enable Analytics Elastic [Behavioral Analytics](https://www.elastic.co/guide/en/elasticsearch/reference/current/behavioral-analytics-start.html) allows you to capture and view simple analytics of search events. The documentation covers the important stuff so I'll just mention how I've implemented it. I host the analytics locally and load it only on the search page, deferred to prevent blocking render: ```html ``` ### Tracking search events On each search execution I run this function to send the relevant analytics event: ```js function sendAnalytics(query,results,total) { if (window.elasticAnalytics) { window.elasticAnalytics.trackSearch({ search: { query: query, search_application: "simonhearne", results: { total_results: total, items: results.map(res=> { return { 'document':{ 'id':res._id, 'index':res._index }, 'page':{ 'url':res.fields.url[0] } } }) } } }); } } ``` ### Tracking search clicks Each search result link has an event listener to send an analytics beacon on click, using data attributes added in the `postTemplate` documented above: ```js function sendClick(e) { if (window.elasticAnalytics) { let el = e.closest('[data-index]'); window.elasticAnalytics.trackSearchClick({ 'document': { 'id': el.dataset.id, 'index': el.dataset.index }, 'search': { 'query': el.dataset.query, 'search_application': 'simonhearne' } }) } } ``` ### Results You should now have a dashboard in Kibana with your search analytics! ![screenshot of kibana search analytics dashboard](https://simonhearne.com/images/search-analytics.png) *Kibana search analytics dashboard* ## Productionising In my case I just wanted something that worked and did not take too much care on production readiness. There are a few further tasks I would complete for a 'proper' deployment: 1. delete the source `ndjson` file after indexing 2. configure separate indices for lower environments 3. take [language into account](https://www.elastic.co/guide/en/elasticsearch/guide/master/one-lang-fields.html) 4. tune the search query based on the analytics results Overall I spent about three hours building this search solution, with an existing elasticsearch cluster ready to use. ============================================================================== # WebPageTest Live: Third Parties and Request Map with Simon Hearne > Third parties have always been a challenge. Having resources loading on your site, beyond your control isn't something users sign up for. In 2015, Simon Hearne created a tool called REQUEST MAP to track just that: requests. This great visualization tool allowed you to see where the request as emerging from, and what was taking place afterwards. In today's episode of WebPageTest LIVE!, Simon Hearne joins us to walk us through this workflow using Request Map -- a supported WebPageTest integration, and how you can use it to see what exact resources are loading, possibly unbeknownst to users. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2022-10-11 Canonical: https://simonhearne.com/2022/third-parties-request-map/ Tags: WebPerf, video, talk Video: https://www.youtube.com/watch?v=TY82pTfhnXY Link: View Speaker Notes: https://notes.simonhearne.com/WebPageTest-Twitch-Stream-Notes-e812abaf4d3445c0ac70678a29ca9e0f --- Most WebPageTest walkthroughs stop at the waterfall. This session was about what you do next. ## About the talk A live WebPageTest session working through real publisher sites, using Request Map to see which third parties are pulling in which other third parties, and where that chain starts costing real time. Alongside the audits it collects the practical mechanics that rarely make it into tutorials: getting past cookie consent banners without breaking your measurement, and a handful of bookmarklets that make WebPageTest's own interface easier to read. ## What's covered - **Live audits.** Global Publishers, Harper's Bazaar and OK Magazine, with Request Map used to trace third-party chains back to whoever invited them. - **Getting past cookie banners, two ways.** Click the button with `execAndWait`, which simulates a first-time visit and can be merged using `combineSteps` or split with `setEventName`; or set the consent cookie directly with `setCookie`, which emulates a repeat visitor and is the better choice for testing holistic performance. - **Why the cookie method needs maintenance.** Consent cookies are time-limited, must be set on the exact page origin including protocol and without a trailing slash, and often need a combination of cookies before the banner clears. - **Five bookmarklets.** Widening filmstrips, widening the run column, reversing comparison order, jumping from SpeedCurve to the underlying WebPageTest result, and opening the Request Map beta from any test. - **A workflow tip.** Leave the Object tab open to skip quickly between images. Recorded as a WebPageTest Live stream. Note that the WebPageTest and Request Map result links in the accompanying speaker notes have since expired. ============================================================================== # Optimising Core Web Vitals on SPAs > Single-page applications present some unique challenges for measuring and optimising core web vitals, here we review the key optimisations you should be making for your SPA Author: Simon Hearne (https://simonhearne.com/about/) Published: 2022-03-22 Canonical: https://simonhearne.com/2022/core-web-vitals-on-spas/ Tags: SPA, SEO, WebPerf, consultancy --- Approximately 40% of my clients have single-page applications (SPAs), and approximately 100% of my clients care about core web vitals - the key performance metrics which impact Google search ranking. Consistently measuring performance of every website and framework is an impossible task, so the core web vitals (largest contentful paint, first input delay and cumulative layout shift) are lowest common denominator metrics. This means that they have been designed to work almost everywhere, but one of the most important aspects of SPAs is missing: route changes (aka in-app navigations). The core web vital metrics were designed to be better proxies of user experience, but we need to be mindful of how they apply to SPAs. There are more details on the **core** web vitals in this post, but in summary: - **LCP**, **FID**, FCP, TTFB, TBT and TTI - only measured on session landing pages - **CLS** (& Responsiveness) - measured throughout a session, the highest / worst value is collected and *attributed to the landing page URL* ## Largest Contentful Paint LCP is my favourite web performance metric; it captures a simple event in a web experience — when the biggest thing is painted to screen — and envelopes all sorts of important elements of the page load such as time to first byte, document size, blocking JS and CSS. Only the first landing page in a session will generate LCP values on SPA sites. The [specification](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint) calls for LCP capture to start at a navigation event and stop *after the first user interaction*. Route-changes / in-app navigations occur after an interaction and thus are not captured by Chrome for the CrUX dataset. LCP is captured for every individual page load with MPAs / traditional web apps. This means that mid-session visitors will likely generate fast LCP values due to having a warm cache and connection - and if your average session length is greater than 4.0 this will probably result in a faster 75th percentile value. As LCP is only captured on landing pages for SPAs, the results are biased towards the worst case experience of an empty cache. This potentially means that for two identical sites, the SPA will report worse LCP values. Whilst mid-session experiences are certainly important for UX, we should focus primarily on landing pages to optimise for Google's core web vitals. This means that we should do everything to reduce the critical path to LCP on a first-view. These first experiences are also crucial to improve user experience and reduce bounce rates! Unfortunately most SPA frameworks don't make this easy out of the box. E.g. if your product image is defined in data then the image element won't even by injected into the DOM until the application JavaScript has downloaded, parsed and executed. This can lead to late asset discovery and correspondingly poor LCP values. Server-side rendering (SSR) is key here: first-views should deliver the page as HTML and allow the browser to apply its optimisations to download content in the correct order. Just ensure that hydration doesn't cause reflow! SSR alone won't save us though. The example waterfall chart below shows an SSRd SPA landing page with some common performance anti-patterns: a very large HTML document and a large amount of preloaded JavaScript assets. Note how these delay the download of the image on line 35, which is the LCP element for this page (LCP is marked as the vertical dashed green line at 5.6s). ![annotated web page test waterfall showing large HTML document and 1.5MB of preloaded JS blocking LCP](https://simonhearne.com/images/cwvs-spas/lcp_waterfall.png) *Use WebPageTest to generate a landing page waterfall and trace back from LCP* I've previously written some advice on [optimising for LCP](https://simonhearne.com/2020/core-web-vitals/#largest-contentful-paint-lcp) which is a good place to start. Note that LCP varies by viewport (e.g. a hero image on mobile might be smaller than your heading text element on desktop). Another LCP nuance is that cookie consent banners may be your LCP element for first-time visitors, so make sure to test and optimise for multiple user states! [Harry Roberts](https://twitter.com/csswizardry) has written up a great summary of [the gotchas with LCP](https://csswizardry.com/2022/03/optimising-largest-contentful-paint/). ## Cumulative Layout Shift CLS is my second-favourite web performance metric; it captures the previously intangible feeling of a page being unstable — how much content moves around when you aren't expecting it. Unlike LCP, CLS can be captured throughout a user's session on a SPA. Unexpected layout shifts are [accumulated in clusters](https://web.dev/evolving-cls/#why-a-session-window): if three shifts of 0.1 occur within one second then your CLS score would be 0.3. CLS clusters close after one second of inactivity or after a maximum of five seconds; importantly this process occurs throughout a user's session on your SPA and the *largest* CLS score by the end of the session *is attributed to the session landing page*. This attribution issue means that identifying causes of layout shifts can be difficult. If you see high CLS reports in Google Search Console they will be logged against the session landing page, but could have occurred at any point in the user's session. CLS has some further nuances: layout shifts are only **unexpected** (and thus count towards CLS) if they are not preceded by a discrete user interaction (click, tap or keypress) within 500ms. This can mean that users on slow network connections report a higher CLS: if for example your search API normally responds in under 500ms from a button press, the subsequent layout shifts are **expected**. In scenarios where the search API takes longer than 500ms, the layout shifts caused by rendering the results will exceed the 500ms threshold and thus become **unexpected**. I call these un-unexpected layout shifts, and on SPAs they can have a significant impact when route changes take longer than half a second. ![screenshot of chrome developer tools showing an unexpected layout shift during a route change](https://simonhearne.com/images/cwvs-spas/unexpected-shift.jpg) *A route change here took longer than 500ms, resulting in an 'unexpected' layout shift* Another nuance in the *expectedness* of layout shifts is the user input types: scrolls do not (currently) count as a discrete user input, so any layout shifts on scroll will count towards CLS scores - thus it is important to have `width` and `height` attributes on lazy-loaded images! Also beware of lazy-loaded components, Simon Wicki has a [great blog post](https://wicki.io/posts/2022-03-cls-with-lazy-loading-components/) on this topic. These nuances mean that optimising for CLS on your SPA is more complex than it may seem at first. I suggest three separate areas of focus: 1. Reduce frequency and size of layout shifts on first-view (easy) 2. Remove layout shifts that occur on scroll (moderate) 3. Investigate potential un-unexpected layout shifts (tricky) This process (especially for 2. and 3.) is manual and time consuming. Where possible, collect web vitals metrics from your route changes using the [web-vitals](https://github.com/GoogleChrome/web-vitals) JS library or a RUM solution like [Akamai mPulse](https://www.akamai.com/products/mpulse-real-user-monitoring) to help identify the layout shifts that your users are experiencing, attributed to the correct URL. ## First Input Delay [FID](https://web.dev/fid/) is one of my least favourite web performance metrics — I have yet to work with a client where their FID numbers showed anything of interest. That said, FID is theoretically a bigger issue on SPAs than MPAs. FID measures the delay between the first user interaction with your page and the browser being able to respond to it. Basically it represents how likely it is that a user is unlucky and tries to interact with the page whilst a long task is consuming the browser main thread. The reason I say that FID is more important to SPAs than MPAs is simply due to the reliance on JavaScript for rendering or hydrating pages. Whether your application is client-side rendered or SSRd, there will be a significant chunk of JavaScript execution during the page load to set the page up. FID values on SPAs are biased towards the worst part of the experience as FID is only captured on the landing page. Optimising FID itself is nearly impossible: the values vary by the visitor's device and the relative timing of their interaction. Instead, focus on reducing total blocking time (TBT). TBT is effectively the sum of long tasks during a page load: a high value indicates that it is more likely that users will try to interact during a long task and thus have a high FID. You can theoretically improve FID by discouraging interaction until JavaScript execution has completed. I have seen this in practice on some very heavy SPAs (e.g. interactive gaming & gambling) where a splash screen is shown until the application is completely ready. Whilst this will improve FID, it could well degrade LCP, introduce [splash-screen SEO penalties](https://www.koozai.com/blog/search-marketing/why-splash-pages-are-bad-for-seo/) and certainly won't have a positive effect on user experience. Back to TBT — there are four key ways to reduce the impact of long tasks and positively impact FID: 1. Remove expensive JavaScript (still using [moment.js](https://momentjs.com/docs/#:~:text=We%20now%20generally%20consider%20Moment%20to%20be%20a%20legacy%20project%20in%20maintenance%20mode)?!) 2. Choreograph long tasks to occur when users are less likely to interact 3. Reduce long tasks by breaking expensive functions into chunks (see [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) for a future option here) 4. Remove all synchronous API calls To find long tasks: set CPU and Network throttling then record a performance profile of a page load. Look for long tasks in the profile and tackle them from longest to shortest. The example below is an innocuous task which depends on a synchronous XHR - it may not even appear as a long task without network throttling! ![screenshot of chrome timeline showing a three second long task due to a synchronous XHR](https://simonhearne.com/images/cwvs-spas/long-task.jpg) *Long task due to synchronous XHR discovered using network throttling* ## Bonus: Responsiveness [Responsiveness](https://web.dev/responsiveness/) is a new web vital being tested by Google. First input delay has received some negative feedback due to being difficult to optimise for and hard to understand. Another issue with FID is that [almost all sites](https://almanac.httparchive.org/en/2021/performance#first-input-delay-fid) (100% of desktop and 90% of mobile) pass the FID assessment, meaning it is not actionable for most site owners. Responsiveness is a potential replacement for FID, and in my clients it often shows a failing result where FID passes. For example a SPA website I'm currently working on: ![screenshot of crux data showing 0ms FID but 400ms responsiveness](https://simonhearne.com/images/cwvs-spas/responsiveness-treo.png) *0ms First Input Delay but 400ms for Responsiveness* The boundaries for good / needs improvement / poor are currently the same as FID: <100ms / <300ms / ≥300ms respectively. My concern for responsiveness on SPAs is that this new metric captures the *full* interaction. FID only measures the time the browser takes to register a user input, whilst responsiveness measures the end-to-end interaction latency; this potentially includes the client-side processing to respond to the user's input and paint the next frame on screen. For SPAs this could include the logic to execute a route change which can often be an expensive process. The metric may in the future also consider scroll events (currently ignored for FID) which could reflect negatively on infinite-scrollers or lazy loaded components. If your responsiveness value is currently high (I love [Treo](https://treo.sh/sitespeed/) for quickly seeing CrUX data), this indicates that user interactions on your site may feel slow. The metric itself is still in flux and won't be impacting the page ranking SEO factor for a reasonable time — Google has said that there will be a six month warning before any changes to the core web vitals are pushed live — but it still makes sense to monitor and optimise for responsiveness. A quick method to determine where a high responsiveness value comes from is to record a profile during an interaction. I use Chrome developer tools for this: load a landing page, then hit record in the performance tab and make an interaction. Look for any delays to rendering the first frame after the interaction and focus your optimisation efforts there. In this case, a click triggers a route change which takes a little over 100ms from the first interaction event to the first frame painted to screen: ![screenshot of chrome developer tools showing long task before frame render after user interaction](https://simonhearne.com/images/cwvs-spas/responsiveness-trace.jpg) *Chrome performance tab shows delays between interaction and next frame paint* To reduce the reported responsiveness value we need to review the long task, the multiple style recalculations and garbage collection event. ## In Summary Core web vitals are useful metrics, and important to optimise for. If you run a SPA focus initially on landing page experiences: these are important not only for the page experience ranking factor but also to reduce bounce rate and improve UX. Don't be dismayed if you have odd results with cumulative layout shift appearing in Google Search Console. Fixing layout shifts is often easy, as long as you can find them! Record a performance timeline whilst navigating your application in multiple device profiles (at least phone, tablet & desktop) and look for unexpected layout shifts with high scores, I tend to focus on any shift with a score of over 0.05. If you are still struggling to get your core web vitals into the green, feel free to [reach out to me](/about/) for further advice. ============================================================================== # Fixing empty responses from Cloudflare Workers Sites > A quick fix for empty response bodies on Cloudflare Workers Sites Author: Simon Hearne (https://simonhearne.com/about/) Published: 2022-02-22 Canonical: https://simonhearne.com/2022/empty-responses-cloudflare-workers-sites/ Tags: development --- I'm currently working on a client project using Cloudflare Workers Sites. Mostly development has been a joy, the `wrangler` CLI is easy to use and production deployment happens in seconds. There was one strange issue reported by the client which left me looking for answers: sometimes the pages wouldn't render, other times no styles were applied. Occasionally images wouldn't render. These issues were obviously critical, and I managed to reproduce them only on the production deployment but not with `wrangler dev`. The cause of the broken pages was empty responses — occasionally a response would appear to be successful (`200` etc.) but there was no content in the body. I had some custom code messing with headers in the document handler, but nothing that modified the CSS or image responses. The biggest clue to the cause of the issue is that it never occurred with a hard reload, only normal navigations or soft reloads. It turns out that in this Workers Sites deployment a `last-modified` header is added to responses by default (there's no code in [the bundle](https://github.com/cloudflare/worker-sites-template/blob/master/workers-site/index.js) which does this). As described in my [blog post on caching](/2022/caching-header-best-practices/), this header allows the browser to emit a conditional GET request with the `if-modified-since` header. The correct response from the server is a `200` with the full response if the browser's timestamp is earlier than the server's, or a `304` empty response if the browser has the latest version of the asset. It appears that this Workers Sites deployment does not correctly respond with a `304` — instead it emits an empty `200` which causes all of the issues we had observed. ![screenshot of developer tools showing a 200 response with an empty body, with last modified response header and if modified since request header](https://simonhearne.com/images/cf_empty_response.png) *Empty 200 responses when if-modified-since request header set.* I'm not a fan of `last-modified` as a caching strategy (especially without a `cache-control` directive) so my quick fix was to delete this response header in the event handler function in `index.js`: ```jsx async function handleEvent(event) { const url = new URL(event.request.url) let options = {} try { const page = await getAssetFromKV(event, options) const response = new Response(page.body, page) response.headers.set('X-XSS-Protection', '1; mode=block') response.headers.set('X-Content-Type-Options', 'nosniff') response.headers.set('X-Frame-Options', 'DENY') response.headers.set('Referrer-Policy', 'unsafe-url') response.headers.set('Feature-Policy', 'none') // delete the Last-Modified response header response.headers.delete('Last-Modified') return response } ... ``` I still need to implement a correct caching strategy, but at least the site works consistently now! I've reviewed other Workers Sites properties that I manage and the `last-modified` response header is not set on those. I'm not sure what is different with this property except that it is the most recent new deployment. ============================================================================== # Survivorship Bias in Web Performance > Why your analytics shows your site as faster than it is, and why FCP is a key metric to optimise for. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2022-02-16 Canonical: https://simonhearne.com/2022/survorship-bias-in-webperf/ Tags: WebPerf --- ## Introduction Way back in 2010, an engineer at YouTube kicked off [Project Feather](https://blog.chriszacharias.com/page-weight-matters): the goal was to reduce the weight of the popular video player page to improve performance. The project was a success: the page weight was reduced from 1,200kB to 98kB and the total number of requests was cut by 90%. The analytics of Project Feather experiences did not reflect the improvements though: the aggregate page load time actually **increased**. Further investigation revealed that traffic from countries with poor connectivity had dramatically increased — users who previously could not even load the page were now able to watch videos. These new visitors had relatively slow experiences, bringing the aggregate values down. There are similar stories from a wide range of websites: - the bank who decided not to invest in mobile web because customers preferred desktop online banking to mobile (the mobile site was so slow it was barely usable) - the retailer who had no iPad 1 traffic so stopped testing on iPad 1 (the website failed to load on iPad 1) - the retailer who had very low Android traffic and assumed their target demographic were iPhone users (Android experiences were twice as slow as iPhone) There is a term for this issue: *Survivorship Bias*. If you search for it term you'll likely see a variation of this image: ![diagram of a plane with red spots to show an aggregate of bullet strikes](https://simonhearne.com/images/survivorship-bias.svg) *The classic illustration of survivorship bias.* The [story goes](https://mcdreeamiemusings.com/blog/2019/4/1/survivorship-bias-how-lessons-from-world-war-two-affect-clinical-research-today) that statistician Abraham Wald used damage logs for aircraft returning from sorties in World War II to identify where reinforcing armour should be applied. The red dots show where bullet holes were found, so the logical assumption would be to reinforce those areas. Wald, however, surmised that the lack of red dots on areas of the aircraft show locations where hits would be critical and result in the aircraft not returning — so the reinforcements should be applied to those areas. Our decisions are influenced by the data we have, but what about the data we don't have? In the military example above it is clear where the data is missing, but the web is slightly more complex. The users who have the worst experiences are likely to be [phantom bounces](https://simonhearne.com/2017/analytics-lie/#phantom-bounces): they don't appear in your analytics or intelligence tools because they don't hang around long enough for the app to load and analytics to fire. ## Speed Bias Google is experimenting with a new web performance metric: **abandonment**. Not to be confused with traditional abandonment metrics like cart abandonment, this is to measure how many users leave a site before it even loads. As part of this research, [Nicolás Peña Moreno](https://twitter.com/NicPenaM) in the [Chrome Speed Metrics](https://chromium.googlesource.com/chromium/src/+/master/docs/speed_metrics/README.md) team used Chrome data to measure the impact of performance on page abandonment rate — i.e. how many visitors leave a page before it loaded. The metric used for the performance side of this equation was [First Contentful Paint](https://web.dev/fcp/) (FCP), so abandoners were potential visitors who left the site before anything useful was painted on the screen. It's worth noting at this point that it is very difficult for website owners to track this data themselves as it is unlikely that any analytics or tracking code has had a chance to run before FCP (and nor should it!). I've taken the [data that Nicolás shared](https://calendar.perfplanet.com/2020/abandonment/) and created a visualisation below to show the scale of the issue. Unfortunately no sample sizes were included in the results so we can't measure statistical significance at the higher FCP numbers, but the trends speak for themselves:
    Loading chart...
    On Chrome Android, every second that FCP is delayed results in an additional 2.6 percentage points of abandonment. To clarify with an example: if your FCP was exactly 4s for all Chrome Android users, your analytics likely under-reports these users by 17.3%. So if your analytics shows one million users, there are another 210k users that abandoned before being logged (`1,000,000 / ( 1 - 0.173 )`). Reducing your FCP by one second would increase your reported visits by 2.9pts or 35,000 (`( 1,000,000 / ( 1 - 0.173 ) ) * ( 1 - 0.144 )`). It is clear that poor performance will impact your traffic numbers, and means that thousands of potential visitors are leaving your site without a trace. It is also clear that mobile visitors are less tolerant of slow sites. There is a secondary issue here: the impact on performance metrics. It is fair to assume from this data that slower websites don't appear as slow as they are (as the slowest experiences will lead to abandonment) and, as we saw with the YouTube Feather example, making performance improvements may not reflect directly in your data. If we take the values from the chart above and apply them to a typical (log-normal) performance distribution we can predict the impact on reported metrics. A sample data set of 10,000 visits is created and the mobile abandonment statistics applied to see the difference in results, try adjusting the goal mean of the distribution and observe the changes in the table below.
    Loading chart...
    MetricActual VisitsExcluding AbandonsDifferenceRelative Difference
    Visits
    Median FCP
    75% FCP
    90% FCP
    95% FCP
    Whilst this data is entirely hypothetical, I hope it helps bring to life the statistics on mobile abandonment shared earlier. Slow sites are more affected by this issue, and so are slow visitors — is your analytics showing a low level of traffic from Android devices because there really are a low number of visitors, or just a high abandonment rate? The difference at high percentiles is worth noting too, your 75th percentile FCP may be reported over a second faster than reality! ## Actions to take It is extremely difficult to measure the impact of early abandonment as a site owner. It is possible to compare analytics with logs, though, and determine the user agents or regions which have the highest disparity between intent to load a page (from server or CDN logs for documents) and successfully loading the page (analytics or RUM data). This strategy has some issues: some requests in your logs will be for crawlers which don't execute your analytics JavaScript and there will also be some disparity between user agents due to visitors using tracker blockers. It is possible to beacon data early in a page load in order to better capture visitors who abandon before traditional analytics scripts fire. [Nic Jansma](https://twitter.com/nicj) has written up an [analysis of strategies](https://nicj.net/beaconing-in-practice/#beaconing-reliability) which shows that beacon capture (on a fast site) can be improved from 86.4% to 92.8% by listening to `pagehide` and `visibilitychange` events in addtion to `onload`. This still requires the JavaScript to execute before the visitor abandons and for the beacon to not be blocked by tracker blockers. Whilst there are strategies to improve our data collection for abandoners, we will always lose some data. The best method to reduce abandonment and increase captured traffic is... improving performance! There are two reasons for this: - faster page loads mean that analytics events fire earlier and are more likely to capture the visitor - faster page loads are a better experience, so visitors are more likely to stay on the page Both of these are positive outcomes! Whilst the focus in web performance has recently been on the *Core* Web Vitals, I encourage you to analyse your First Contentful Paint with as much scrutiny as Largest Contentful Paint, Cumulative Layout Shift or First Input Delay. FCP was used by Nicolás in the abandonment study because it is such an important indicator of user experience. ![Web Page Test filmstrip showing a gap of blank screen before first contentful paint](https://simonhearne.com/images/fcp_simon.jpg) *FCP marks when your visitor can see something useful* FCP marks the context switch from the previous page or a blank screen to showing something that the visitor actually expects to see, so in my analysis it is often the primary metric I focus on improving. If FCP is high, it means that visitors are more likely to think that the page is not working, they have time to reconsider what action they were taking and can easily hit the back button. Note that FCP [isn't a great cross-browser metric](https://blog.webpagetest.org/posts/why-first-contentful-paint-doesnt-work-as-a-cross-browser-metric/), so your mileage may vary when tracking it over time and across browsers and operating systems. That does not necessarily mean that it is a bad metric, it's just complex to implement consistently across the different browser engines. The key points for optimising FCP are universal, though: - reduce time to first byte (TTFB): use a CDN, cache HTML pages - reduce blocking JS and CSS - defer non-critical JS - remove render blocking third-party tags Expect to see the unexpected in your real user analytics when shipping performance improvements! Technically reducing FCP may in fact increase your reported FCP as more visitors on low-end devices and connections successfully load your pages. Expect to see traffic numbers change for different device types, and combine your performance metrics with business metrics to get a more holistic view of the success of each technical performance improvement. ============================================================================== # The value of an independent web performance consultant > If you're wondering if you need an independent web performance consultant, this is why I think you do! Author: Simon Hearne (https://simonhearne.com/about/) Published: 2022-02-04 Canonical: https://simonhearne.com/2022/value-of-a-consultant/ Tags: WebPerf, consultancy --- I have been employed in web performance roles for about a decade. From load testing and synthetic monitoring to CDN configuration and true consultancy. In 2022 I became an independent web performance consultant. One thing has stood out from my experience working with hundreds of brands — from small national websites to massive multi-national ecommerce websites: folks generally understand web performance, why it matters and the core principles, but they struggle to keep on top of industry trends whilst maintaining a complex production codebase. Sometimes all it takes is a call that brings together marketing, product and development teams to talk it through; other situations call for a multi-year project to gradually introduce optimisations and measure change. Coming in as a consultant allows me to be a catalyst for positive change, even if all of the performance issues had previously been documented! Dedicated, independent consultants (like me, or others in the industry) help to bring focus and passion to a topic that is often under-valued and under-invested. This quote from Rob Kerr on self-employment resonates with me:
    I believe that, given the right preparation, self-employment can be empowering for the individual and can solve meaningful problems in the world. Many of these problems would never justify the investment at a corporate level, and nor could a big business deliver the solution to the same standard as the person who is passionate about making that difference.

    — Project Future by Rob Kerr

    In most organisations it does not make sense to employ a full-time individual who is solely focussed on site speed. Some of the more successful organisations I've worked with have web performance working groups - a cross-functional team who have a few days per month to discuss and prioritise performance improvements. Even in these cases, having an external view on site speed adds incredible value: reviewing the KPIs that are being measured, prioritising backlogged tasks, improving tooling and even identifying new issues that have been undetected by the in-house teams. Independent performance consultants add further value because of the work we do across multiple clients: working in different codebases on different platforms for different types of organisation. We absorb best practices from multiple teams and can help apply them to each of our clients. If nothing else, an experienced second set of eyes on a website will always find something to improve. Speaking of improvements: measuring impact is one of the most difficult parts of web performance in general, and is a real challenge when it comes to consultancy. I would love to be able to attribute performance optimisations back to real business impact, but this is near impossible in a world of daily releases, multiple campaigns per week and the rollercoaster of SEO / SEM. We know from [hundreds of case studies](https://wpostats.com) that improving performance improves user experience and business results, so most times we have to rely on that implicit understanding. In an ideal world performance changes are [A/A/B](https://blog.analytics-toolkit.com/2014/aa-aab-aabb-tests-cro/) tested to determine the impact on business results, using a real-user monitoring (RUM) solution like [Akamai mPulse](https://www.akamai.com/products/mpulse-real-user-monitoring) or [SpeedCurve](https://www.speedcurve.com/features/performance-monitoring/) to track performance + business metrics together. Even then, some performance improvements will impact metrics that are near-impossible to track: e.g. user satisfaction, return rates and likelihood to recommend. Improving performance *will* improve business metrics, it's just nearly impossible to measure the direct impact. [Katie Sylor-Miller](https://twitter.com/ksylor) recently made the same observation about the difficulties in attributing positive performance changes to business results: Another difficult part of web performance is determining the impact of a technical change at a user experience level. Reducing time to first byte (TTFB) by 500ms will have a very clear benefit: every experience, every pageview, will become a half a second quicker. But most changes — like reducing CSS bundle size, optimising images or reducing JavaScript execution times — will not have such a linear relationship. Improving page load time by 100ms under test conditions may not seem like a lot, but at the tail-end of your user distribution this could result in more than a second of improvement. Users in more remote geographies, on older phones, on poorer quality networks will always see a bigger improvement than users on a flagship phone connected to a 100+Mbps network. Implementing a RUM solution and providing deep analysis of the data can help to illuminate the users having the worst experiences and to identify the most important optimisations to focus on. **In summary**: performance matters to user experience, even if it is difficult to prove. An independent performance consultant can help to bring teams together, to identify and prioritise performance enhancements and to measure their impact. As a bonus: onboarding a consultant is almost certainly quicker (and cheaper) than developing and maintaining in-house expertise to the same level - all while sharing knowledge internally and helping to build a performance culture. ============================================================================== # Caching Header Best Practices > Caching headers are surprisingly complex and often misconfigured. Here we look at some key cache scenarios and recommend the ideal headers to set. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2022-01-27 Updated: 2022-01-28 Canonical: https://simonhearne.com/2022/caching-header-best-practices/ Tags: WebPerf --- ## Introduction Caching headers are one of those deceptively complex web technologies which are so often overlooked or misconfigured. The fastest request is the one that is not made, and caching headers allow us to tell browsers when they can reuse an asset that they have already downloaded. The reason that these headers are often misconfigured, or at least configured suboptimally, is often through a descent to lowest risk. Allowing a browser to use a cached asset can be considered risky - JS which falls out of sync with HTML, CSS which persists an old campaign style, personalised assets accidentally being shared between visitors. In this post we will review what caching headers are available and when they should be used. We'll also talk about invalidating caches and ensuring browsers use the correct assets at the correct time. Note that the focus of this post is on client-side (or downstream) caching - in the client device. Caching in proxies, load balancers and Content Delivery Networks (CDNs) adds some more complexity, and is not covered here. ### The Solution Use versioned assets wherever possible (e.g. `main.v123.min.css` or `main.min.css?v=123`) and set a single caching header allowing the maximum cache duration of one year: ```http Cache-Control: max-age=31536000, immutable ``` For non-versioned assets which may change, combine the Cache-Control header with an ETag for asynchronous revalidation in the client: ```http Cache-Control: max-age=604800, stale-while-revalidate=86400 ETag: "" ``` For HTML files, set a low TTL and private cache flags: ```http Cache-Control: max-age:300, private ``` Do not emit unnecessary caching headers (including `ETag` and `Last-Modified`) to prevent unexpected client and server behaviours. ![Flowchart of decisions to determine which caching headers are best in different scenarios. The logic is described fully in the document](https://simonhearne.com/images/caching-header-best-practices/cache-headers.svg) *A summary of the logic to determine which caching headers are best in different scenarios.* ### Caching Headers There are a number of response headers set by a web server or CDN which manage client-side caching. Some are more obvious than others! `Expires` - a date (in GMT) after which this asset may no longer be used from the browsers cache and must be re-fetched ([docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires)) `Cache-Control` - a combination of features in one header, including how long the resource can be cached by the client (in seconds) as well as whether proxies can cache it, whether to force revalidation and more ([docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)) `ETag` - a string that uniquely identifies an asset version, generally a server-generated hash of the file ([docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)) `Last-Modified` - a timestamp which allows browsers to validate the freshness of cached assets ([docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified)) `Pragma` - a hangover from HTTP/1.0, this should generally not be used in preference for `Cache-Control` except where HTTP/1.0 clients *must* be supported ([docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Pragma)) In general I recommend to not emit an `Expires` header and rely instead on the more comprehensive `Cache-Control` header. I also recommend to not emit a `Last-Modified` header and use `ETag` instead for asset revalidation, this avoids edge cases such as newer files with identical content or clock mismatches between web servers which would cause unnecessary bandwidth consumption. For revalidation (aka conditional requests) to work, responses must be served with one or both of the `ETag` or `Last-Modified` headers. The server must also understand conditional get requests and respond with a `304 Not Modified` in the case that the cached asset matches that on origin. Note that validation of weak ETags (prefixed by `W/` in the header) are unsupported in some scenarios — including if using Akamai as a CDN — so you may want to use strong ETags where possible.

    ETags are simply strings which identify a specific version of an asset. Weak ETags (prefixed with W/) should match assets which are semantically the same (e.g. metadata has been updated but the content is the same), whereas strong ETags should change whenever the asset is changed in any way. (See RFC 7232 for more info!)

    By default, Apache 2.3.14 and earlier included INode in the ETag - meaning that the ETag for an identical asset would change between servers! This is no longer included by default, and you can configure what is used to produce the ETag in Apache. The default is to use the last-modified time and the file size, but you can also choose to use a file digest, and manually include the INode back into the calculation.

    If you have your own versioning implemented on the web server you could generate ETags yourself. E.g. ETag: core-js-es6-v13.1234-gzip. But then you might as well rename the file to break the cache on the front-end.

    ## Caching Behaviours There are generally four types of caching behaviour we may want a browser to use when it has downloaded a static asset: ### 1. Not Cacheable For assets that are dynamically generated, that are unique or that are only valid once. `Cache-Control: no-cache` This directive tells the client that it can cache the asset, but it cannot use the cached asset without revalidating with the server. If the asset cannot be revalidated (i.e. there were no `ETag` or `Last-Modified` response headers on the asset) then the cached asset will never be used. `Cache-Control: no-store` This directive tells the client that the asset may not be stored in cache at all. Any further requests for this asset will be full requests back to the server. ### 2. Immutable These assets that can be stored by the browser indefinitely because they never change. Use this for versioned assets (e.g. `main.v123.min.css` or `main.min.css?v=123`). ```http Cache-Control: max-age=31536000, immutable ``` The `immutable` directive explicitly tells the browser ([where supported](https://caniuse.com/mdn-http_headers_cache-control_immutable)) that the asset can be stored for a year and never needs to be revalidated. ### 3. Time-restricted For assets which should be stored for the duration of a session (e.g. one day or one week) but should be refreshed or revalidated if the visitor returns later. ```http Cache-Control: max-age=86400 ``` ### 4. Revalidated When combined with #3, this allows browsers to use a cached asset for a period of time (from zero seconds up to a year) and then revalidate the object with origin once that period has expired. `stale-while-revalidate` causes the revalidation request to happen asynchronously, improving performance at the potential risk of using stale content with a second time to live (TTL) value for how long the stale asset may be used. ```http Cache-Control: max-age=604800, stale-while-revalidate=86400 ``` ## Optimal Caching Strategy In general we want browsers to cache everything forever. This can be achieved quite simply by setting a `Cache-Control: max-age=31536000` response header - using the maximum TTL value of one year. The issue is that your web applications likely change more frequently than yearly. This is where the most important feature of your web build pipeline comes in - versioned assets! With versioned assets, the browser will automatically ignore stale cached assets as the references will be updated. Instead of the HTML document requesting `main.v123.min.css`, the reference will be to `main.v124.min.css` (note the incremented version number, this will be automatically generated at build time and may be an asset hash). If assets will always be versioned, we can consider them `immutable`. That means that if the content of the file changes we guarantee that the filename is changed. Some browsers support this concept natively in the `cache-control` header, preventing revalidation requests ever being made for these assets: ```http Cache-Control: max-age=31536000, immutable ``` ### Query Strings and Caching If an asset filename cannot be updated automatically, a query string parameter can be added to the URL to break the cache. For example `main.min.css?v=124`. This method should be robust in most cases, ensuring that your CDN configuration treats query strings as part of the asset cache key for caching at the CDN level: - Cloudflare [includes query strings by default](https://developers.cloudflare.com/cache/how-to/set-caching-levels) - Akamai [excludes query strings by default](https://developer.akamai.com/blog/2017/04/14/what-you-need-know-about-caching-part-3#how-query-parameters-affect-cache-keys) - Fastly [includes query strings by default](https://docs.fastly.com/en/guides/manipulating-the-cache-key) ### Irregular Updates to Unversioned Assets One of the trickiest caching scenarios to manage is where an asset is unversioned (no unique identifier in the URL) and it is updated on an irregular schedule. This could be, for example, a document containing stock information, transport schedules or feature flags. In this case, we still want the browser to be able to use the cached asset but also ensure that it remains relatively fresh. This is (in my opinion) the only scenario where entity tags (ETags) make sense. ETags should be used in combination with a valid `Cache-Control` header to ensure you have control over how the browser manages its cache state: ```http Cache-Control: max-age=86400, must-revalidate ETag: "a-unique-hash-generated-by-the-server" ``` This example will allow the browser to use the cached asset for up to 24 hours (86,400 seconds), after which it must revalidate with the server. A revalidation request (also known as a conditional GET request) acts just like a normal request for the asset, with the addition of one or two request headers: `If-None-Match` where an ETag was present on the original response, `If-Modified-Since` in the case that a Last-Modified header was present: ```http GET /main.min.css Accept: */* Accept-Encoding: gzip,br If-None-Match: "a-unique-hash-generated-by-the-server" ``` This `If-None-Match` header is a message to the server that the client has a version of the asset in cache. The server can then check to see whether this is still a valid version of the asset - if so, we will receive an empty `304` response with another ETag which will match the original: ```http 304 Not Modified ETag: "a-unique-hash-generated-by-the-server" ``` If the asset has changed since the client cached it, we will get a full response with the new ETag: ```http 200 Found Content-Length: 100000 ETag: "a-NEW-unique-hash-generated-by-the-server" ``` This approach is valuable, but does have drawbacks: - Generating & maintaining asset hashes has a small compute cost on the server - Some web servers have bugs with ETag generation and validation, especially with multiple servers behind a load balancer - A `304` response will add a small front-end delay for each request and incurs a small amount of compute on the web server - the client will not use the (valid) cached asset until the `304` response is received A similar process occurs when the `Last-Modified` header is present on responses. You may see conditional requests sent with the `If-Modified-Since` header: ```http GET /main.min.css If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT ``` If both `If-Modified-Since` and `If-None-Match` request headers are present then the server must only return a `304` if **both** conditional fields match the origin content. The `If-Modified-Since` header allows the server to check the last modified time of the requested asset and again return a `304 Not Modified` if it is the equal to or earlier than the timestamp in the request. Using `Last-Modified` may cause more cache-misses than `ETag` if releases touch all files on the web server, even if they have no updates (updating the last modified time on all files). There is a benefit to using `Last-Modified` over `ETag` in the edge case where servers may hold older versions of an asset - the `ETag` will not match and result in a full `200` response, whereas the `Last-Modified` date will be later than the origin asset, resulting in an empty `304` response. ## What about HTML? HTML assets are critical to performance in traditional (non-SPA) web applications. A 100ms delay downloading the HTML for a page will make everything else 100ms slower. Folks are normally very nervous about allowing browsers to cache HTML though, for a number of good reasons: - Personalisation in the page (e.g. user name, basket contents, geolocation logic) - Asset versions (e.g. `main.v123.min.css`) are updated in the HTML to purge the client cache on release - Rapid updates (e.g. a news publication with breaking stories) Even with all of these considerations, caching HTML can still be achieved and performance can be significantly improved. We just need to be careful! ### Short TTLs & private caches Allowing an HTML asset to be cached for a short period can improve user experience with minimal risk. Setting a TTL of five minutes (300s) for example should be fine, you can also add `must-revalidate` to ensure that browsers do not use a stale version of the asset after the TTL has expired. This will benefit visitors who click links returning them to previously visited pages, this does not affect the [back/forward cache](https://web.dev/bfcache/). The `Cache-Control` header offers the `private` attribute, indicating that assets should not be stored by any proxies or CDNs, but may be cached by the client. This attribute enables you to allow browsers to cache personalised content - as the cached asset will not be shared across multiple visitors. ### Remove dynamic elements from the HTML document Dynamic elements such as a basket count, user name, logged in / out status indicator all make caching HTML more tricky. Externalising these out to API calls, JavaScript and localStorage will mean that the base HTML template can be cached in the browser, with dynamic elements updated as the page is building. This will also allow pages to be cached by CDNs - greatly reducing Time to First Byte (TTFB) and thus improving experience. This concept can be taken further in some scenarios, such as when anonymous visitors are presented generic / un-personalised pages. In this case, you can detect the anonymous visitor (e.g. by presence of a user cookie) and serve them a cached page from the CDN, whereas logged-in / recognised visitors will be served by origin to allow personalisation code to run. The possibilities here are quite exciting! ## General Recommendations Explicitly set the `Cache-Control` header on all responses. Do not set the following response headers in most scenarios: - `Last-Modified` - `Expires` - `ETag` - `Pragma` Always set the `Cache-Control` header, preferably with the value `max-age=31536000,immutable` alongside unique asset filenames (or `no-cache` for non-cacheable assets). In some scenarios, use ETags to allow browsers to revalidated cached content using the following headers (e.g. cache for one week, allow stale assets with async revalidation for up to one day after cache expiry): ```http Cache-Control: max-age=604800,stale-while-revalidate=86400 ETag: "" ``` The lack of `Last-Modified` and `ETag` headers on a response prevents the browser from making conditional GET requests, potentially reducing back-end load. Ensure that your web application server / load-balancer / CDN handles conditional GET requests correctly and returns a `304 Not Modified` response when ETags match. Use `cURL` or [redbot.org](http://redbot.org) to test this. ## How to check your headers Throughout this post I have detailed headers that you might see when requesting assets - of course these aren't visible on the web application directly! There are two key methods to check the response headers of your assets: using the browser and using command-line tools. ### Browser developer tools All browsers have developer tools to help analyse network traffic, in Google Chrome you can press ⌘ + ⌥ + i ( ctrl + shift + i on Windows) to bring up Chrome Developer Tools, it will default to the last open tab so you may need to select the network tab. The network tab only records traffic whilst open, so reload the page to view the requests if none are present. Once you have some traffic, click on a request to see more details: ![screenshot of chrome developer tools showing response headers](https://simonhearne.com/images/caching-header-best-practices/header-chrome-devtools.png) *Chrome Developer Tools showing response headers* If this is a task that you will repeat often, I recommend adding the relevant response headers to the network table. Right-click on the column headers and select `Response Headers`, `Cache-Control` should be there by default and you can also add custom headers here. ![screenshot of chrome developer tools showing response headers in a column](https://simonhearne.com/images/caching-header-best-practices/header-chrome-column.png) *Chrome Developer Tools showing a column for Cache-Control headers* ### Command-line tools [cURL](https://curl.se/) is one of the most widely deployed command line tools and is ideal for checking response headers. A simple command such as `curl 'https://simonhearne.com/css/main.css?v=4.1' -Is` will show you all response headers for the object, although we can build on this to focus just on the headers that we care about for caching (note that I have added some required request headers `user-agent` and `accept-language` so that a valid response is provided): ```bash curl 'https://phoenixnap.com/kb/wp-includes/css/dist/block-library/style.min.css?ver=5.9' \ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36' \ -H 'accept-language: en-GB,en-US;q=0.9,en;q=0.8' \ --compressed -Is | grep -E 'cache-control|etag|last-modified|expires|pragma' last-modified: Wed, 26 Jan 2022 05:24:24 GMT etag: W/"61f0db08-1357b" expires: Thu, 26 Jan 2023 11:29:02 GMT cache-control: max-age=31536000 pragma: public cache-control: max-age=31536000, public ``` For this example, I would recommend that the website owner investigates the various response headers here which may conflict with each other. This set of six response headers could be replaced with a single header: `Cache-Control: max-age=31536000`! Redbot also shows a number of warnings for this implementation: ![screenshot of redbot showing warnings about response headers](https://simonhearne.com/images/caching-header-best-practices/redbot.png) *Redbot showing potential issues with response headers (view on redbot.org)* ## How to change response headers Once you have determined your caching header strategy and reviewed the headers that are currently being emitted - it's time to make some changes! Response headers can be set at multiple stages depending on your application architecture - for example the web server, load balancer and CDN can all set and modify response headers. Generally we should expect response headers to be forwarded transparently through proxies such as load balancers and CDNs unless explicit changes have been made, so the web server is the best place to start. You should be able to find what you need with a search for ` set response headers` or ` set caching headers` on your search engine of choice. For convenience, here are the relevant docs pages for some major web servers, PaaS / SaaS and CDN solutions: ### Web Servers - Apache - [ETags in Core](https://httpd.apache.org/docs/2.4/mod/core.html#FileETag) - [mod_headers](https://httpd.apache.org/docs/2.4/mod/mod_headers.html) - [mod_cache](https://httpd.apache.org/docs/2.4/mod/mod_cache.html) - [nginx](https://www.digitalocean.com/community/tutorials/how-to-implement-browser-caching-with-nginx-s-header-module-on-ubuntu-16-04) - [h2o](https://h2o.examp1e.net/configure/headers_directives.html) ### PaaS / SaaS - [Netlify](https://docs.netlify.com/routing/headers/) - note that there is an [open issue](https://answers.netlify.com/t/setting-response-headers-only-on-documents/6144/40) regarding setting headers by content-type. - [Wix](https://support.wix.com/en/article/site-performance-caching-pages-to-optimize-loading-speed) - note that Wix does not provide fine-grained control over caching headers - [WPengine](https://wpengine.com/support/cache-control-headers-wp-engine/) - [Webflow](https://discourse.webflow.com/t/setting-http-header-fields/180953/4) - it doesn't look like you can control headers and the recommendation is to use a CDN in front of Webflow ### CDNs - [Akamai](https://techdocs.akamai.com/api-gateway/docs/caching#downstream-caching) - [Fastly](https://docs.fastly.com/en/guides/controlling-caching) - [Cloudflare](https://developers.cloudflare.com/cache/how-to/set-browser-ttl) - lets you set a TTL but no fine-grained control over response headers - [Cloudflare Pages](https://developers.cloudflare.com/pages/platform/headers) - [Cloudflare Workers](https://developers.cloudflare.com/workers/examples/alter-headers) - I use a worker in front of Netlify to [overcome limitations](https://answers.netlify.com/t/setting-response-headers-only-on-documents/6144/40) in netlify.toml. Provides absolute control over response headers. ## A note on Cache-Control: public A lot of resources (e.g. on [web.dev](https://web.dev/http-cache/)) recommend setting the `public` attribute on `Cache-Control` headers if resources can be shared between visitors. This is potentially misleading as `public` [is the default behaviour](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#public) where `private` is not set. The `public` attribute should **not** be set in almost all cases, as it can lead to an issue with authorized requests. Setting the `public` attribute in a `Cache-Control` header for an authorized request (i.e. one which is made with an `Authorization` header) will explicitly allow intermediary servers to store and serve the response to other visitors. In most cases this is not the expected behaviour and could lead to data leakage. ## In closing… Client-side caching is a key technique to improving front-end speed and user experience. Whilst it may appear complex and risky, investing the time to review your content and setting the correct response headers will reduce bandwidth utilisation and improve speed for return visitors as well as mid-session. There are multiple methods to manage caching, in this article I have presented a preference for `Cache-Control` (and `ETag` where appropriate). Exact implementation is not as important as correctness and consistency, though. Use what works for your application architecture, environment and processes. ============================================================================== # Fast and Responsive Hero Videos for Great UX > Responsive landing page hero videos are increasingly popular, here we show how to make them as fast as possible. Author: Simon Hearne (https://simonhearne.com/about/) Published: 2021-11-02 Updated: 2022-06-28 Canonical: https://simonhearne.com/2021/fast-responsive-videos/ Tags: WebPerf --- Landing page hero videos are increasingly popular, especially in high fashion and luxury goods. Hero videos can have a detrimental effect on user experience though, delaying page load times and causing the page to jump around. In this post we will take an example homepage and see how we can optimise it to deliver fast, responsive videos for great UX. Jump straight to the [conclusion](#conclusion) to see the results for each optimisation (in total we halve the video load time) and see the final working demo with code!
    Mobile mockup of the demo homepage playing a tall portrait rain video above placeholder text Tablet mockup of the demo homepage playing a wide landscape waterfall video above placeholder text
    The example website uses different aspect ratio videos for narrow and wide viewports
    Consider this simple homepage with a hero video and content below the video. The video has been created in two aspect ratios: one for mobile / portrait viewports and another for desktop / landscape viewports. It is common to see this developed along the following lines: ```html ``` ```css /* ==== style.css ==== */ .full-width-video { background-color: black; width: 100%; } ``` ```js /* ==== app.js ==== */ const DESKTOP_BREAKPOINT = 480; window.addEventListener("load",function() { let heroVideoEl = document.getElementByID("hero-video"); let heroVideoSrc = heroVideoEl.dataset.srcMobile let viewportWidth = window.innerWidth; if (viewportWidth >= DESKTOP_BREAKPOINT) { heroVideoSrc = heroVideoEl.dataset.srcDesktop; } heroVideoEl.src = heroVideoSrc; }) ``` Note that the `