<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Medium Engineering - Medium]]></title>
        <description><![CDATA[Stories from the team building Medium. - Medium]]></description>
        <link>https://medium.engineering?source=rss----2817475205d3---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Medium Engineering - Medium</title>
            <link>https://medium.engineering?source=rss----2817475205d3---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Tue, 20 Oct 2026 03:45:00 GMT</lastBuildDate>
        <atom:link href="https://medium.engineering/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[What We Built for Medium Editors This Year (and What I Broke)]]></title>
            <link>https://medium.engineering/what-we-built-for-medium-editors-this-year-and-what-i-broke-1213c2746703?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/1213c2746703</guid>
            <category><![CDATA[publications-on-medium]]></category>
            <category><![CDATA[technology]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Scott Batson]]></dc:creator>
            <pubDate>Tue, 29 Sep 2026 18:09:45 GMT</pubDate>
            <atom:updated>2026-09-29T18:09:44.084Z</atom:updated>
            <content:encoded><![CDATA[<h4>A behind-the-scenes look at bulk declines, decline reasons, a content calendar, and one very bad hour</h4><p>A few months ago, we took an honest look at publications on Medium and asked if they were succeeding. Were users incentivized to create quality publications on Medium?</p><p>We had the Boost Nomination Program, which was helpful for the cohort that was selected, but we didn’t have any signal that the BNP was motivating for <em>other</em> users. Were we getting higher quality articles as a result of BNP?</p><p>So we asked ourselves “How can we reward good editors for their editorial work and make it clear how others can earn that reward?”</p><p>That’s when we came up with the Editor Partner Program (EPP).</p><p>The first thing we introduced for EPP was the ability to assign an editor to a given submission. Before, there was no way to tell if a publication editor was actively working on a post. And since we wanted to start compensating editors directly, we needed a way (in the data) to know who worked on a given submission.</p><p>So we introduced the ability for editors to assign themselves to submissions in the inbox.</p><figure><img alt="image.png" src="https://cdn-images-1.medium.com/max/1024/9472830bf7a9c4cd498b83437afe60abd01f36fbbf9b5887902671c20e940b5d" /><figcaption>Screenshot of the new editor assignment tool</figcaption></figure><p>Even for those not in the EPP, this was a useful organizational tool.</p><p>I’m not going to go in depth on EPP, but I wanted to flag it as the catalyst for a lot of the recent changes we’ve made and the ones we’re making in the near future.</p><p>We knew we wanted to encourage publications to be “open” because we wanted more writers to find a good community of readers. But we knew what being “open” could mean for editors. Lots of slop submissions.</p><blockquote>Open submissions doesn’t feel fair … unless we give them something that makes their lives better.— Direct quote from a meeting</blockquote><h3>Scott, just solve AI slop… done</h3><p>I wish I had a magic wand to remove all AI slop. We’ve looked into a lot of tools and they just aren’t reliable. They flag human writers as AI a lot.</p><p>Some publications are also <em>fine</em> with AI-written articles. Who am I to tell them no?</p><p>Rather than throwing up our hands and saying “this is unsolvable,” we thought about mitigation tools. The first was obvious: bulk blocking and bulk declining.</p><figure><img alt="image.png" src="https://cdn-images-1.medium.com/max/1024/a987fe3b306cc246effa74beac9096f9b33922e3b3329bf5057a834ef9b72155" /><figcaption>Screenshot of our bulk block writers tool</figcaption></figure><p>That was something I could write and ship in a day. It was out the door so fast that we thought “what else can we do?” And “how do we decide <em>what</em> to do?”</p><figure><img alt="image.png" src="https://cdn-images-1.medium.com/max/1024/2ba602bfc5763180ae6638de3ee5982fafabdc15c839e466e3e24e9745757006" /><figcaption>Screenshot of the confirmation modal used for blocking writers</figcaption></figure><h3>Workshop on how we might improve editors’ lives</h3><p>I’m a sucker for a good workshop. Coworkers coming together and throwing ideas at the wall to see what sticks.</p><p>We had an hour-long workshop with people from all over the organization. It was important that it wasn’t just engineers. We pulled people from our community team, trust and safety, design, content, and more.</p><p>None of us know everything. But we all see the product at different altitudes. And it was important that we had people there who actually talk to users regularly.</p><figure><img alt="Screenshot 2026-09-17 at 10.44.35 AM.png" src="https://cdn-images-1.medium.com/max/1024/76cf9348b823b96837c6f895173dcb860fdd2dbda047ee356eaaa94556c0a837" /><figcaption>A sloppy screenshot of our first figma impact measurement brainstorm</figcaption></figure><h3>Manage and reduce spam</h3><p>The first thing we focused on was to build on top of the bulk block and remove feature, and focus on other ways to reduce and mitigate slop.</p><p>We came out of the workshop with some easy wins. The first was a way to more easily see what submissions were related to your publications’ topics.</p><figure><img alt="image.png" src="https://cdn-images-1.medium.com/max/1024/9170cf5c7cafd3c17fc37a071e793b425642a156108fa1535f2c1cf64810b95e" /><figcaption>Screenshot of the off-topic and no-topic pills</figcaption></figure><p>We had ways to quickly sift out the spam, but could we introduce more tools to cut down on the spam in the first place?</p><p>The next thing we wanted to give editors was the ability to set stricter submission rules for their publication. These would be hard stops that prevent people from submitting until all the boxes are checked. We gave editors tools to (hopefully) spot and remove slop, next let’s give them a tool to keep the slop out of the inbox in the first place.</p><figure><img alt="image.png" src="https://cdn-images-1.medium.com/max/1024/e5cb0cee5979e47c4baebc77885c16a54542253d3a40ee1edc20dbf94560b07f" /><figcaption>Image of some of the publication submission settings</figcaption></figure><p>This also helped writers. Some would have their stories rejected immediately by editors because they didn’t include a subtitle or an image.</p><p>Now editors were only getting stories that more closely followed their rules. Of course, that doesn’t mean we removed all spam and it certainly didn’t mean that every submission was high quality (even if well-intentioned).</p><p>Continuing down our list of mitigation tools, one point of frustration editors run into is a user who publishes too much. When you see a submission, you only see that post in a vacuum. There’s no way to see if that person publishes constantly (i.e. they are trying to spam).</p><p>So we added more to the inbox by giving you a quick glimpse at their recent publishing history:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/13bfb226e43497f4bb4917f142008cf5bf964a9c28cd8d7175f17ab8ea71dbc6" /><figcaption>Image showing the publishing history of a user</figcaption></figure><p>Writers who earnestly submitted something and got declined kept telling us the same thing: they didn’t know why. Why was my story rejected? Unless the editor left a private note, there was no indication whatsoever.</p><p>We introduced preset “reasons” for declining. It made the ecosystem feel less anonymous and gave writers something actionable to work on.</p><figure><img alt="image.png" src="https://cdn-images-1.medium.com/max/1024/283490d71ad9123b707515b8c0fb0852deeaf0683ffcdd5109036a62f6ee6dea" /><figcaption>Screenshot of the decline reason dropdown</figcaption></figure><h3>Make running a publication easier</h3><p>With some of those light, mitigation tools in place, we also wanted to focus on ways we could improve managing a publication (that had nothing to do with removing spam).</p><p>We had feedback from editors that poor images and the inability to easily set how images are displayed made their publications look unprofessional.</p><p>That’s what inspired us to introduce a tool that easily allows you to set the focal point of an article’s preview image. This is a change that made it to every user, not just editors.</p><figure><img alt="new-focal-point.gif" src="https://cdn-images-1.medium.com/max/1024/49b1c848265bb5a44b15ee1559167fd101286317f57a9b4cd486201d13ae4aa5" /><figcaption>Gif of the new image focal-point adjuster</figcaption></figure><p>Listening to more feedback, we heard from editors that scheduling could be a pain. It required clicking into each post to make changes, and there wasn’t an easy way to see your schedule other than a list.</p><p>So we built out a content calendar. You could see the full schedule for your publication and drag-and-drop items between days to reschedule. No more submenus or guesswork.</p><figure><img alt="Screenshot 2026-09-17 at 1.31.59 PM.png" src="https://cdn-images-1.medium.com/max/1024/717571888eea8e5ba23b24817864c109e3dba8983489e4540115b4623f8c5fec" /><figcaption>Screenshots of the new publication calendar</figcaption></figure><p>We also made it easier for you to edit and manage the writers for your publication. The new “Editors &amp; Writers” page lets you quickly search your list of editors and writers, add new ones, and even download a CSV of everyone who contributes to your publication.</p><figure><img alt="Screenshot 2026-09-17 at 1.42.39 PM.png" src="https://cdn-images-1.medium.com/max/1024/9218ade2b0e1870cad90517ac32b6a29d6f179252dc3eada8a0166bed2bb777b" /><figcaption>Screenshot of the new “Editors &amp; Writers” page</figcaption></figure><h3>Misses Things we learned</h3><p>The catch-22 of listening to user feedback is that sometimes you get feedback on the solution to the prior feedback.</p><p>We shipped the ability to assign editors, which we used to mark payouts for the editor partner program. But some publications only have 1 editor. A few users left comments saying it didn’t make sense to have to assign themselves, given they were the only editors.</p><p>So we shipped “auto assignment” for publications with only 1 editor.</p><p>When we showed off the “off topics” flag, most people liked the idea, but the biggest piece of feedback we heard was “5 tags isn’t enough.”</p><figure><img alt="Screenshot 2026-09-29 at 10.11.00 AM.png" src="https://cdn-images-1.medium.com/max/1024/6c3a0a2891ab6a02243dc32daf626c6364777839df0723bf51c3bf07a62fac80" /><figcaption>Comments from users</figcaption></figure><p>Why could publications only set 5 tags? I dug into it and… no one knows. That decision was made 10 years ago 🤷. Nobody who worked on it still works at Medium, and the ticket linked in the comments was for an old Jira instance I couldn’t access. So we upped it to 10 to give publications more tags to choose from.</p><p>The “decline reasons” were a pretty well-received feature. Only problem was, I completely broke submissions for an hour. Oops.</p><p>For about an hour, nobody could submit a story to a publication because a migration hadn’t run when I deployed that feature, so no one could write to the submissions table.</p><p>It was an easy fix, but it’s the price of moving fast sometimes…</p><p>So, what did we actually learn? Sometimes fixes need fixes, and we have to create bandwidth for that. Our users have a great sense of what is working, so we try our best to listen and think through the impact. That could mean changing something we literally just built, and that’s okay.</p><h3>What’s next?</h3><p>People want a customizable “decline reason.” i.e. an open text box to describe why something was declined. I do too.</p><p>The problem is that as soon as we introduce an open text field, we introduce a vehicle for harassment. So we’d have to build reporting and Trust &amp; Safety tools into it. That turns a small quality-of-life feature into a much bigger project.</p><p>We’re going to work on a Publications Directory. A better way for writers to find publications that resonate with their writing. We’re still early in planning, but we want to help people find your publication!</p><p>We’re working on editor tools that better help you communicate with authors in the draft. This would let you have a faster back-and-forth with authors and show them the changes you’ve made to their draft before publishing.</p><figure><img alt="changes-in-line.gif" src="https://cdn-images-1.medium.com/max/1024/328ae273fd16e8ffee3f53d27557268592090ee0037d46cdda8ea1a03f51034a" /><figcaption>Gif of the current Beta version of the submission review page</figcaption></figure><h3>Where can you send feedback?</h3><p>Use <a href="https://help.medium.com/hc/en-us/requests/new">Medium’s support form</a> to send feedback or report an issue. For tips on writing a useful ticket, see <a href="https://help.medium.com/hc/en-us/articles/216287508-Contact-Medium-Support">Contact Medium Support</a>.</p><p>Or you can leave a comment here! I swear, I do read them. Just be forewarned, I might use them in a blog post in the future.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1213c2746703" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/what-we-built-for-medium-editors-this-year-and-what-i-broke-1213c2746703">What We Built for Medium Editors This Year (and What I Broke)</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How we think about text classification in the LLM era]]></title>
            <link>https://medium.engineering/how-we-think-about-text-classification-in-the-llm-era-89a185f79b68?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/89a185f79b68</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[text-classification]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <dc:creator><![CDATA[Raphael Montaud]]></dc:creator>
            <pubDate>Wed, 19 Aug 2026 20:00:37 GMT</pubDate>
            <atom:updated>2026-08-19T20:00:36.635Z</atom:updated>
            <content:encoded><![CDATA[<h3>Why we think LLMs can be useful and why we will not replace all of our models with them</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MkYq_MYsJnyv418AXICeqw.jpeg" /></figure><h3>Context</h3><p>At Medium, we have many Machine Learning models that we use to label stories automatically. These affect what stories we recommend to readers.</p><p>Here’s some examples:</p><figure><img alt="Three examples of models that classify stories based on their content. An NSFW model and a Spam model each produce a single score, and a threshold decides the label — NSFW or SFW, Spam or Ham. A Topics model instead scores a long list of topics at once, and the ones scoring high enough are selected as the story’s topics." src="https://cdn-images-1.medium.com/max/1024/2884f65e559ed6527932f01aca16fc5fef2854ab94fd232084627e4aba64b1e3" /><figcaption>a few of our text classification models. All diagrams and charts made by the author</figcaption></figure><h3>Some Clarifications on our Machine Learning policy</h3><p>Before we go deep on this project, I just wanted to clarify a few things about how we stand regarding AI in general.</p><p>Medium has been training internal models with user and post data for a long time now. We train models with specific tasks. For example, models that power our recommendations algorithm, or text classification models like the ones presented in this story. All in the goal to improve our product. With the LLM approach I describe in this story, we ARE NOT sharing these models with other companies. And we ARE NOT allowing anyone to train on our users’ data and content. Here we used Snowflake LLM tools for <strong>inference only (no LLM training was done here) </strong>and they are actually hosting all of the models inside their own infrastructure and guarantee that they are not using any of this for training. Shoutout to the Snowflake team for making it so easy and safe to use LLMs on our data!</p><p>If you want to read more about Medium’s stance on AI, I definitely recommend giving these a read:</p><ul><li><a href="https://medium.com/blog/default-no-to-ai-training-on-your-stories-abb5b4589c8">Default No to AI Training on Your Stories</a></li><li><a href="https://medium.com/blog/finally-an-internet-standard-for-writers-rights-vs-ai-companies-07ffe09f15f8">Finally, an internet standard for writers’ rights vs. AI companies</a></li><li><a href="https://medium.com/blog/we-want-your-feedback-how-can-writers-use-ai-to-tell-human-stories-eb9dee926f2e">We want your feedback: How can writers use AI to tell human stories?</a></li></ul><h3>Problem</h3><p>During our roadmap planning we decided that our NSFW model was out of date and it was time to revamp it. This model labels stories as “Not Safe for Work” if they have sexually explicit content, lots of profanity, or basically anything you wouldn’t want to read on your big monitor in the middle of an open space!</p><p>As you can imagine it’s a pretty important model. We really need it to make sure our most “interesting” content only reaches our most “interested” users and ONLY them!</p><p>There’s actually a funny anecdote from 2021 that was shared internally back then. One of our employee was onboarding the WHITE HOUSE staff onto Medium so that they could start using the platform with the POTUS account. And the first thing that showed up on the homepage was a big picture of well… a butt… So that became of whole thing <strong><em>“Our algorithm is serving erotica to President Joe Biden!!”. </em></strong>That was a brand new account so that was our top story “by default”.</p><p>That’s embarrassing for us and for our users! This anecdote is actually what prompted the recommendations team to create the first version of our NSFW model (so, thank you Joe!).</p><p>We’re now in 2026 and this model is now pretty old. It’s not getting retrained regularly, the code runs on python versions that are extremely old. With turnover and changes in tools and file organization, we lost track of how it was trained and what kind of performance was measured at the time. So this is now an obscure part of the ML stack, in need of a good makeover.</p><h3>LLM based approach</h3><p>The typical way we’d build a binary classification model like this is to build a pipeline where:</p><ul><li>new data is regularly added to a training set by a human labeling team</li><li>and the model is regularly retrained</li></ul><figure><img alt="The typical machine learning setup, in two loops. In the training loop, humans label new data, those labels get added to the training set, the model is retrained on a daily or weekly cadence, and the retrained model is published. In the prediction loop, new data arrives — a story published on Medium — the latest published model makes a prediction on it, and the prediction is stored." src="https://cdn-images-1.medium.com/max/1024/cdd9ce532966bb51dd59598d4e47df84918fd8cbf98a392f729bc6aa0a726787" /></figure><p>So now, what if we replace all that with a really simple LLM based approach?</p><p>With LLMs it’s pretty easy to create whatever text classifier you want. You just need the proper prompt and have some sort of expected output that you can parse from the LLM’s response. And there’s no need for training at all.</p><figure><img alt="The LLM-based approach. Four pieces are assembled into a single prompt: instructions asking whether the post qualifies as NSFW and requesting a 0-to-100 score with an explanation, a set of NSFW guidelines, a handful of labelled examples, and the post’s content. The LLM returns a score and a written explanation. For example:score=12 expalanation=“nothing matches NSFW guidelines”" src="https://cdn-images-1.medium.com/max/1024/099039b8365f50f3ee0ac3e78082290871df1ddecaea50eb119ec432690c19bc" /><figcaption>We build a prompt with all the context needed for the LLM to make a decision. We get back a score between 0 (perfectly SFW) and 100 (undoubtedly NSFW) as well as a short explanation of the score.</figcaption></figure><p>We thought this NSFW model revamp was a great opportunity to give this a try. Are we able to build a NSFW classifier this way, that matches our performance expectations?</p><h3>How we built it</h3><p>The process was pretty simple</p><p><strong>First we built an evaluation dataset:</strong></p><ul><li>we sampled a random sample of stories published on Medium</li><li>then we had our curation team label the dataset (is it NSFW? yes/no)</li><li>as they did that, we also asked them to refine their definition of “NSFW”, and give concrete examples (this was very useful to build the prompt)</li></ul><p>Side note: when we picked the random sampling of stories, we used other signals to make sure we oversample NSFW stories. If we just sample at random, we’d have to sample thousands of stories just to get a handful of NSFW examples. Thanks to this, our evaluation had a nice 50/50 split on NSFW/SFW labels.</p><p><strong>Next step was to build the prompt for the LLM:</strong></p><ul><li>we leveraged all the clarifications that the curators added while labeling the dataset and summarized that into “NSFW guidelines”</li><li>we pass in the story’s contents</li><li>we ask the LLM to output a score from 0 → 100</li><li>and we also ask the LLM to give a short explanation for the score. For example: “graphic description of sexual intercourse”</li></ul><p><strong>Finally we need to evaluate the LLM approach and compare the different model:</strong></p><ul><li>have a few LLMs make their predictions on the evaluation set</li><li>have the legacy model make it’s predictions on the evaluation set too</li><li>and then compare the metrics</li></ul><figure><img alt="How the evaluation works. A dataset of story contents with a human-assigned yes/no NSFW label is run through each candidate model — the legacy model, Claude Haiku, Mixtral 8x7b and others — and each one gets a precision and recall score that can be compared side by side. Most land around 95% on both, while the smaller Claude Haiku shows high precision but much lower recall." src="https://cdn-images-1.medium.com/max/1024/ce45ef8c4c7aae3a67f28ea6ba05f1ad5297a8da2de87f55714c1babd0702b10" /><figcaption>Thanks to our new evaluation dataset, we can compare our legacy model with LLM models</figcaption></figure><p>Great news, after some testing, we found some LLM models that match or outperform the legacy model! We got parity with the legacy model on both false positives and false negatives. Some of the bigger LLMs even outperform the legacy model on both metrics. The mistral models performed really well for us, as well as some of the larger Claude models.</p><p>Performance is key here because there’s big downsides with both false negatives and false positives:</p><figure><img alt="A confusion matrix showing why both kinds of error hurt. When a story is actually SFW but the model flags it NSFW, that’s a false positive: the story doesn’t get distributed even though it should have, and the writer loses out. When a story is actually NSFW but the model flags it SFW, that’s a false negative: it might get distributed to users who don’t expect it — the Joe Biden problem. The other two quadrants, true positives and true negatives, are the correct outcomes." src="https://cdn-images-1.medium.com/max/1024/767b18e9b30b7a8cadf5a5718ad13c257a2b20def19c360e6bf391eea4d0faef" /></figure><p><strong>Now, we need to get an idea of the costs: is that LLM approach going to cost us an arm and leg?</strong></p><p>Something nice with LLMs is that the costs are easy to estimate. Each model has a cost per input token and a cost per output token. Using that and a little bit of back of the enveloppe math you can quickly get a really good cost estimate.</p><figure><img alt="A three-step simplification of the LLM cost equation. The full version — prompt size plus content size, times price per input token, plus output cost, all times the number of posts scored — gets reduced by crossing out the negligible parts. What’s left is prompt size × price per input token × number of posts. That worked out to about $250 a month with Mixtral 8x7b." src="https://cdn-images-1.medium.com/max/1024/1*Mbj9erwiOG3vnLdwWTvVdg.png" /></figure><p>We then picked the model that was the best fit for us in terms of costs and performance. For us that was Mixtral 8×7b (I promise that was a fair trial - nothing to do with the fact that I’m French!). The main way we kept the costs under control is by picking a relatively cheap model, and also by only scoring the stories that are eligible to get distributed in the first place (for example, no need to waste time on stories that have been flagged as spam).</p><p>For offline experimentation and for production classification, we simply used Snowflake. All of our data ends up in Snowflake and they make it super simple to run LLM inference directly inside a SQL query. There’s a complete function available that lets you call an LLM and pick your model as well as the expected response format.</p><p>Here’s the query I used during the evaluation process as an example:</p><pre>set modelName = &#39;claude-haiku-4-5&#39;;<br>set promptName = &#39;raph_test&#39;;<br>set promptVersion = &#39;1.3&#39;;<br><br>set responseFormat = &#39;{<br>  &quot;type&quot;: &quot;json&quot;,<br>  &quot;schema&quot;: {<br>    &quot;type&quot;: &quot;object&quot;,<br>    &quot;properties&quot;: {<br>      &quot;score&quot;:  {&quot;type&quot;: &quot;integer&quot;},<br>      &quot;reason&quot;: {&quot;type&quot;: &quot;string&quot;}<br>    },<br>    &quot;required&quot;: [&quot;score&quot;, &quot;reason&quot;]<br>  }<br>}&#39;;<br><br>with raw_llm_responses AS (SELECT post_id,<br>                                  title,<br>                                  text,<br>                                  SNOWFLAKE.CORTEX.COMPLETE(<br>                                    $modelName,<br>                                    -- build prompt: instructions and post information<br>                                    ARRAY_CONSTRUCT(<br>                                      OBJECT_CONSTRUCT(<br>                                        &#39;role&#39;,<br>                                        &#39;system&#39;,<br>                                        &#39;content&#39;,<br>                                        prompts.prompt<br>                                      ),<br>                                      OBJECT_CONSTRUCT(<br>                                        &#39;role&#39;,<br>                                        &#39;user&#39;,<br>                                        &#39;content&#39;,<br>                                        &#39;&lt;title&gt;&#39; || COALESCE(title, &#39;N/A&#39;) || &#39;&lt;/title&gt;&#39; || &#39;\n&#39; ||<br>                                        &#39;&lt;text&gt;&#39; || COALESCE(text, &#39;N/A&#39;) || &#39;&lt;/text&gt;&#39;<br>                                      )<br>                                    ),<br>                                    OBJECT_CONSTRUCT(<br>                                      &#39;temperature&#39;, 0,<br>                                      &#39;response_format&#39;, PARSE_JSON($responseFormat)<br>                                    )<br>                                  ) AS llm_raw_response<br>                           FROM posts_to_evaluate<br>                                  join medium.ml.nsfw_classifier_prompt as prompts<br>                           where prompts.name = $promptName<br>                             and prompts.version = $promptVersion),<br><br>     -- parse results from the returned JSON<br>     llm_responses_parsed AS (SELECT post_id,<br>                                     title,<br>                                     text,<br>                                     llm_raw_response,<br>                                     llm_raw_response:structured_output[0]:raw_message AS parsed_json,<br>                                     TRY_CAST(parsed_json:score::STRING AS INT)        AS score,<br>                                     parsed_json:reason::STRING                        AS score_reason,<br>                                     parsed_json::STRING                               AS response_as_string<br>                              FROM raw_llm_responses)<br><br>SELECT post_id,<br>       score              AS prediction,<br>       score_reason,<br>       response_as_string as raw_response,<br>       $modelName         AS model_name,<br>       $promptName        AS prompt_name,<br>       $promptVersion     AS prompt_version<br>FROM llm_responses_parsed;</pre><p>In our tech stack, it’s really easy to setup a simple job that runs a Snowflake query on a schedule. <strong>We just leveraged that and we were able to successfully ship that model very quickly.</strong></p><h3>And now repeat?</h3><p>Once that new model was up and running, we were really interested in applying that same formula to our other models. We first sat down to weigh the pros and cons of that new LLM based approach, here’s how we broke it down:</p><figure><img alt="A table comparing a custom ML model with an LLM. The custom model needs a training set, adapts automatically as new labels come in, and is much cheaper — but shipping a new version is hard and it offers no explainability. The LLM needs only an evaluation set, is easier to iterate on and somewhat explainable, but is more expensive and frozen in time, so the prompt and evaluation set need maintaining." src="https://cdn-images-1.medium.com/max/1024/fdb02663c14c90798aec78a6e0508f0aaf9a135219209a8f63f4ed2e7d3355e2" /></figure><p><strong>Generally the LLM approach is:</strong></p><ul><li>easier to setup</li><li>more expensive</li><li>less adaptive</li></ul><p>One thing to keep in mind too is that LLMs are “stuck at a point in time”. They have been trained at a certain date and they don’t know anything about what happened after that date. So they will not be adapting to new trends and performance is likely to drop over time. Depending on the use case, you might want to upgrade your model every now and then.</p><p><strong>The NSFW model was the perfect use case:</strong></p><ul><li>we don’t have a proper training set for NSFW content. And it would be costly to create and maintain one</li><li>we don’t feel like we need to be super reactive to new trends. I don’t really have data to support that, this is more a judgment call: we think that NSFW stories 6 months from now will be similar to the ones that are published today.</li><li>costs are low as long as we only score a portion of the stories published on Medium. Here we just need to score the stories that are eligible for distribution in the first place</li></ul><p><strong>All in all, that model revamp was a great success:</strong></p><ul><li>we have a brand new NSFW model</li><li>it performs really well</li><li>we don’t need to maintain a training set</li><li>it’s cheap to run</li><li>and it’s really simple in terms of engineering</li></ul><p>What about our other models then?</p><figure><img alt="Two notes on our other models. For the topic model, the dataset is free, and switching to an LLM would mean no longer adapting to new trends. For the spam model, an LLM would be expensive because there are so many posts to score, and a static LLM plus a fixed prompt is a problem in a cat-and-mouse game." src="https://cdn-images-1.medium.com/max/1024/16282e84bca7bea052a33fb776f18382b226fb4bbc9e34d621641cf372fd59af" /></figure><p>It turns out that the NSFW model is maybe our only model that’s a good use case for an LLM based approach:</p><ul><li>for our topic model, we get our dataset for free (we just train on the topics that users are adding to their stories). This means that we automatically adapt to new trends, which is really nice. Switching to an LLM based approach would feel like a downgrade</li></ul><figure><img alt="A Medium story with its topic tags highlighted at the top" src="https://cdn-images-1.medium.com/max/1024/096201a4b36fc95caa03b02ae32aec4b0a279c8c7131f8b725413db4a72f082b" /><figcaption>writers add topics to their stories and we leverage that to build a training dataset “for free”. The model automatically adapts to new trends as new stories get posted on Medium</figcaption></figure><ul><li>for the spam model, we also want to adapt to new trends <strong>quickly</strong>. Spammers are always trying new things and they are always trying to trick the platforms to get visibility. So in this case we are willing to pay the cost of maintaining a training set, augmented with new data every day. Also since we want to score ALL the stories posted on Medium, it becomes more complex and costly to use an LLM based approach</li></ul><figure><img alt="A Medium story titled “How to Keep a Glass Wine Cellar From Looking Like a Display Case,” tagged Architecture, Wine and Design. The writing is bland and generic, and the first sentence contains a link" src="https://cdn-images-1.medium.com/max/1024/83514975a67f2a08ae5f48828f8038b77efbca39368e1220ff98cbbbb7e4c311" /><figcaption>some freshly caught spam (SEO hacking)</figcaption></figure><h3>What about new use cases?</h3><p>Even though that doesn’t work really well for our other existing models. It is still very exciting because it makes it really easy to spin up new text classification models. What if we wanted to give users more control on their content, like “allow erotica” but do not allow “true crime”? We’re a text-based platform, so the possibilities are endless!</p><h3>Some side notes</h3><h4><strong>Why ask for scores and not just a yes/no value?</strong></h4><p>With scores, you are able to rank the predictions and it means that you can use metrics that quantify how well you separate the NSFW stuff from the SFW stuff (metrics like ROC-AUC or PR-AUC). It also means that you can set the threshold wherever you like and you can change that in the future too - letting you choose the balance of false positives and false negatives that suits you best.</p><figure><img alt="The same six stories ranked by NSFW score, shown with two different thresholds. At a threshold of 90, only the top story is flagged, so two genuinely NSFW stories slip through as false negatives. At a threshold of 30, almost everything is flagged, so a perfectly SFW story gets caught as a false positive and won’t be distributed. Scores let you move that line wherever you want." src="https://cdn-images-1.medium.com/max/1024/a28f14b2b086ae74b87b2405d7c313b3c4a0c2c0115d13d845f144f27385f158" /></figure><h4>Why ask for a short description?</h4><p>Our end goal is just to know if a story is SFW or not, we don’t actually need the LLM to explain it’s decision. And it does add a little to the inference costs (the output becomes longer). But in our use-case it was negligible in the overall costs.</p><p>I had to do a lot of spot checking and debugging on individual stories for this project. (I learned a lot of things 😳). And having the descriptions alongside the score was super useful for that. It’s also a great thing to have now that this is production, and it can also be used to break down NSFW stories into categories. That can be useful for the curation team or the trust and safety team</p><figure><img alt="A results table with three columns: story title, prediction, and reason. Four stories scored 90 or 95, each with a one-line explanation from the model — “explicit sexual content in one”, “selling illegal drugs online”, “erotic content” for the two other examples." src="https://cdn-images-1.medium.com/max/1024/32710f156963e1e8a59007feccfab42a09032e8160051ec8e1fb906f6b84abde" /><figcaption>spot checking and debugging is easier with a short explanation</figcaption></figure><figure><img alt="A bar chart, “Distribution of NSFW posts by high level category.” “Explicit sexual content” is by far the largest share at around 37%, followed by “violence and gore” at roughly 12% and “erotica” at about 10%. “Drugs”, “pornography” and “hate speech” each account for a few percent or less." src="https://cdn-images-1.medium.com/max/1024/ef1ab231c42f32208336c21319df4aecf123e9f920010b371cff322ea0f97bde" /><figcaption>asking for a description lets you break things down nicely</figcaption></figure><p><strong>To wrap this up, it was really fun to build this model and we’re excited to build new features with this new tool in our toolbox! We’re hoping this opens up new possibilities for where our product teams can take Medium next and helps us make sure readers find more stories they love, and writers get more readers that love their work!</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=89a185f79b68" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/how-we-think-about-text-classification-in-the-llm-era-89a185f79b68">How we think about text classification in the LLM era</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How we built the new Table of Contents feature]]></title>
            <link>https://medium.engineering/how-we-built-the-new-table-of-contents-feature-c3825d8c279d?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/c3825d8c279d</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Scott Batson]]></dc:creator>
            <pubDate>Thu, 23 Jul 2026 14:28:55 GMT</pubDate>
            <atom:updated>2026-07-23T14:28:55.332Z</atom:updated>
            <content:encoded><![CDATA[<h4>How a feature goes from idea to prototype to shipped</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0a_BM_NexU9ckvnh" /></figure><p>For years, there’s been this really popular <a href="https://chromewebstore.google.com/detail/toc-medium/ppcdgggkanhfoghepgomkbbnkggbiecn?pli=1">Chrome extension</a> for adding a table of contents to your Medium stories. Props to <a href="https://www.vdeantoni.com/">Vinicius<strong> </strong>De Antoni</a> for writing a popular chrome extension all those years go.</p><p>The extension was so popular, we even wrote about it in our Medium Handbook.</p><p><a href="https://medium.com/medium-handbook/how-to-make-a-table-of-contents-for-your-medium-story-8ecb44f842b7">How to make a table of contents for your Medium story</a></p><p>The fact that it was so popular was a signal to me. There was a decent size of our user base that wanted this feature. So, at first I set out to just copy it.</p><p>The Chrome extension extends the popup menu we have in the editor to add a new option. Just a styled button on the existing list.</p><pre>const inject = () =&gt; {<br>  const tooltipToggleMenu = document.querySelector(<br>    &quot;[data-action=inline-menu]&quot;,<br>  );<br>  const tooltip = document.querySelector(&quot;.inlineTooltip&quot;);<br>  const tooltipMenu = document.querySelector(&quot;.inlineTooltip-menu&quot;);<br>  const addEmbedButton = document.querySelector(<br>    &quot;.inlineTooltip-menu [title=&#39;Add an embed&#39;]&quot;,<br>  );<br><br>  if (!tooltip || !tooltipToggleMenu || !tooltipMenu || !addEmbedButton) {<br>    return;<br>  }<br><br>  tooltip.style.width = &quot;auto&quot;;<br><br>  const toCButton = document.querySelector(&quot;[data-action=&#39;inline-menu-toc&#39;]&quot;);<br>  if (toCButton) {<br>    return;<br>  }<br><br>  const handleAddToCClick = (e) =&gt; {<br>    const container = document.querySelector(&quot;.is-selected&quot;);<br>    tooltipToggleMenu.click();<br><br>    container.innerHTML = generate().join(&quot;&lt;br/&gt;&quot;);<br><br>    setTimeout(() =&gt; {<br>      simulateKeydown(container, 13);<br>    }, 0);<br>  };<br><br>  const addToCButton = addEmbedButton.cloneNode(true);<br><br>  const title = &quot;Add a Table of Contents&quot;;<br>  addToCButton.setAttribute(&quot;title&quot;, title);<br>  addToCButton.setAttribute(&quot;aria-label&quot;, title);<br>  addToCButton.setAttribute(&quot;data-action&quot;, &quot;inline-menu-toc&quot;);<br>  addToCButton.setAttribute(<br>    &quot;data-action-value&quot;,<br>    &quot;Generate a table of contents&quot;,<br>  );<br>  addToCButton.setAttribute(&quot;data-default-value&quot;, &quot;Table of contents&quot;);<br>  addToCButton.innerHTML = &quot;⋮&quot;;<br>  addToCButton.style.color = &quot;rgb(26, 137, 23)&quot;;<br>  addToCButton.style.border = &quot;1px solid&quot;;<br>  addToCButton.style.fontWeight = &quot;bold&quot;;<br>  addToCButton.addEventListener(&quot;click&quot;, handleAddToCClick);<br><br>  tooltipMenu.appendChild(addToCButton);<br>};</pre><p>(Code taken from the <a href="https://github.com/vdeantoni/toc-medium/blob/master/tooltip.js">open source Chrome extension</a>)</p><p>The button has an event handler that runs some JavaScript to look at the content of the post, find all the headings and creates an unordered list of links for each.</p><pre>const generate = () =&gt; {<br>  const headingList = [...document.querySelectorAll(&quot;h3[name], h4[name]&quot;)];<br><br>  return headingList<br>    .filter(<br>      (headingTag) =&gt;<br>        !headingTag.classList.contains(&quot;graf--title&quot;) &amp;&amp;<br>        !headingTag.classList.contains(&quot;graf--subtitle&quot;),<br>    )<br>    .map((headingTag) =&gt; {<br>      let bullet;<br>      switch (headingTag.tagName.toLowerCase()) {<br>        case &quot;h1&quot;:<br>        case &quot;h2&quot;:<br>        case &quot;h3&quot;:<br>          bullet = `·`;<br>          break;<br>        case &quot;h4&quot;:<br>          bullet = `  ∘`;<br>          break;<br>      }<br><br>      return `${bullet} &lt;a href=&quot;#${headingTag.getAttribute(<br>        &quot;name&quot;,<br>      )}&quot; title=&quot;${headingTag.textContent}&quot;&gt;${headingTag.textContent}&lt;/a&gt;`;<br>    });<br>};</pre><p>(Code taken from the <a href="https://github.com/vdeantoni/toc-medium/blob/master/tooltip.js">open source Chrome extension</a>)</p><p>It inserts that list into the body of the post itself.</p><p>For me, this is the first downside. Mutating the post directly feels… not great. It doesn’t play well with our revision model and from writer’s perspective, adding stuff into the post I spent so much time writing can skew the reading experience.</p><p>Still, most people don’t mind the look and feel of it. The real issue, however, is that it isn’t dynamic. You click the button and slaps that list in your post. But what if you revise your article later? Add a new section with a new header and suddenly that table of contents isn’t accurate.</p><p>Then there was the usability of it. It was always inserted at the <em>top</em> of the post. Which meant that once you used it to jump to the bottom section, you’d have to scroll back to the top to use it again.</p><p>The list also just jumped you straight to the section. That’s how anchor links work by default, but it could feel abrupt if you didn’t quite know what was happening.</p><p>I thought we could do better. A dynamic solution that writers don’t have to maintain.</p><p>The principals are still the same though: the list should be built off of content of the post. That’s our source of truth. If the author revises it, they shouldn’t have to worry about maintaining some index of all their headings.</p><p>So, we build the table of contents based on the post’s body at render time. Why at render time? Well, if you didn’t know our post model is… complicated. We don’t store the full, finished post in our backend. We store a series of revisions that we play over one another to land on the final version. The end user doesn’t notice the difference, but it allows authors to step back through different versions of their article.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/930/0*EiBCbDi6OpIOqLbh" /><figcaption>Screenshot of the revision history dropdown in the editor</figcaption></figure><p>Rather than trying to traverse this complicated model, it was best to just honor the end result of the post and create a table of contents at run time.</p><p>Much like that Chrome Extension, we build a list based off the headings.</p><pre>/**<br> * Derives a nested table of contents from a post body model.<br> *<br> * In Medium&#39;s model, in-body H2 and H3 both render as the larger section heading<br> * while H4 renders as the smaller sub-heading. So H2/H3 become top-level entries<br> * and H4 sub-headings nest beneath the most recent top-level heading. The post&#39;s<br> * lead title/subtitle/kicker are excluded because getParagraphStyles remaps them<br> * to &#39;Title&#39;/&#39;Subtitle&#39;/&#39;Kicker&#39;. An H4 that appears before any top-level heading<br> * is promoted to top level so nothing is dropped.<br> */<br>export function buildTableOfContents(bodyModel: TocBodyModel | null | undefined): TocEntry[] {<br>  if (!bodyModel) return []<br>  const styles = getParagraphStyles(bodyModel as Parameters&lt;typeof getParagraphStyles&gt;[0])<br>  const entries: TocEntry[] = []<br>  let currentTopLevel: TocEntry | undefined<br><br>  bodyModel.paragraphs.forEach((paragraph, index) =&gt; {<br>    const style = styles[index]<br>    const name = paragraph.name?.trim()<br>    const text = paragraph.text?.trim()<br>    if (!name || !text) return<br><br>    if (style === ParagraphType.H2 || style === ParagraphType.H3) {<br>      const entry: TocEntry = {name, text, children: []}<br>      entries.push(entry)<br>      currentTopLevel = entry<br>    } else if (style === ParagraphType.H4) {<br>      if (currentTopLevel) {<br>        currentTopLevel.children.push({name, text, children: []})<br>      } else {<br>        entries.push({name, text, children: []})<br>      }<br>    }<br>  })<br><br>  return entries<br>}</pre><p>Then, it’s a matter of how we display it. My first attempt was to just add it to the 3-dot-menu at the top of the post:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*WFisH94SIOvRJBsD" /><figcaption>Screen recording of my first attempt</figcaption></figure><p>I’m not a designer and I don’t have the best UX sense, but even I could tell this wasn’t quite right. As some people pointed out, the biggest downside was that you couldn’t access the Table of Contents from anywhere. You had to be at the top of the post.</p><p>Luckily, I work with some great designers and we were able to noodle out some ideas. There’s a lot of prior art on the internet for breaking up sections on a page. Look at Notion, Codex, Graphite, and many other apps.</p><p>Rather than adding another item to that popover menu, what if we had something that lived in the right margin and scrolled with you?</p><p>Better yet, what if it could tell you <em>where</em> you were within the body of the text?</p><p>Our next solution did just that. Long lines for main headings, short lines for subheadings. The “current” heading would highlight in green and update as you scrolled.</p><h3>Edge cases</h3><p>Good enough to ship, right?</p><p>Well, almost. The more we played around with it on production, the more edge cases we could see.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ldr8iEjh46gufWjx" /></figure><p>We had to account for images that took up the whole width of the page. We did that by adding a translucent background. This way, it’s always visible without being overly abrupt.</p><p>What about posts that have only one heading? Well, we just hide it entirely. There’s kind of no point.</p><p>What about posts that have one hundred headings? When we tested it, the table of contents went right off the page! Not a typical case, but there are plenty of long papers published on Medium, and they are arguably the ones that could benefit the <em>most</em> from a table of contents.</p><p>So we went with a sliding window that only shows a few at a time. Hover over it and you’ll see the full list.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/0*rynevOyvJUjrA2gX" /><figcaption>A gif showing table of contents on a post with full-width images</figcaption></figure><p>Thanks <a href="https://medium.com/u/6790f45c21d5">Marco Capriz</a> for writing this post that was great for testing the table of contents!</p><p><a href="https://thequantasticjournal.com/how-to-build-a-dyson-sphere-000400212601">How to Build a Dyson Sphere</a></p><p>At this point, we felt good about the desktop solution. How did it feel on mobile?</p><p>Pretty bad…</p><p>We have a much smaller right margin on mobile, so the indicators were overlapping with the post content. We could squish them down, but then they were barely visible and the touch box was too small.</p><p>How do other apps handle it? Notion completely hides their table of contents on mobile. Most likely, they ran into the same issue.</p><p>We decided that mobile table of contents needed to have their own solution. What makes sense for a desktop user doesn’t always work for someone on their phone. More to come on that.</p><p>Overall, I think we landed on a great solution.</p><ul><li>Dynamic list users don’t have to maintain</li><li>Clickable from anywhere in the post</li><li>Smooth scrolling</li><li>It doesn’t add any content to the post itself</li></ul><p>Unlike the Chrome Extension, we can also add metrics to this, so we here at Medium can see if users are actually using the feature. The old Chrome Extension required writers to create and maintain these lists, but they are ultimately a tool for <em>readers</em> not writers.</p><h3>Will this hurt my earnings?</h3><p>Before we shipped, I had the question of “will this hurt scrolling metrics that affect reading time?”</p><p>The short answer is: no. We don’t calculate valid reads based on scrolling behavior. We might have in the past, but that was before my time 🤷. Plus, there was nothing stopping a user from quickly scrolling a post before, we just allowed them to do it a more usable way.</p><p>So don’t worry about this feature affecting earnings. We weight earnings based on engagement (claps, highlights, reading time). If a user uses the table of contents to quickly scroll your stuff and then bounce, they didn’t actually read anything and they probably wouldn’t have read it without this tool.</p><p>Overall, we think quality of life changes like this help with user retention. Which means <em>more</em> readers stick around because they have a more positive experience.</p><h3>How long did it take to build?</h3><p>All-in-all, this feature took a couple of days to finish. A lot of that was UX testing and iterating on the design to make it feel right. The code is pretty straightforward once we landed on a solution.</p><p>Expect us to ship more quality of life improvements like this in the near future!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c3825d8c279d" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/how-we-built-the-new-table-of-contents-feature-c3825d8c279d">How we built the new Table of Contents feature</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Outcomes we want to see from AI at Medium Engineering]]></title>
            <link>https://medium.engineering/outcomes-we-want-to-see-from-ai-at-medium-engineering-10891d52a19f?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/10891d52a19f</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Jacob Bennett]]></dc:creator>
            <pubDate>Tue, 26 May 2026 15:33:52 GMT</pubDate>
            <atom:updated>2026-05-26T15:33:51.819Z</atom:updated>
            <content:encoded><![CDATA[<p><em>This was originally written and published by </em><a href="https://medium.com/u/299ca19f11f7"><em>Christophe Spy</em></a><em> (Director of Engineering, Medium) as an internal Medium Eng post.</em></p><h3>Desired outcomes (what “good” looks like)</h3><p><strong>🙏🏻<em> These are the kinds of changes we want to see if AI is going well at Medium.</em></strong></p><h4>Less drudgery, more leverage</h4><p>Engineers use AI to remove repetitive, low‑leverage work (e.g. tedious refactors, boilerplate, large test suites), not just to ship more tickets.</p><p>We see examples like:</p><ul><li>“We took a part of the codebase that was basically untestable and, with AI, made it testable and added meaningful coverage in days instead of months.”</li><li>“We cleaned up a legacy area we had been afraid to touch for years, using AI for the mechanical work.”</li></ul><h4>Faster learning and broader technical range</h4><p>Engineers use AI to ramp on new languages, frameworks, and parts of the stack much more quickly.</p><p>Engineers can explore unfamiliar areas safely by asking AI to explain tradeoffs, pitfalls, risks, and how the system uses this areas of the code.</p><p>We see fewer cases where work stalls or we avoid implementing things because “nobody knows this code anymore”.</p><p>Frontend/mobile engineers are able to make safe, reviewable backend changes with AI’s help (plus a review from a backend engineer).</p><p>Teams can ship more end‑to‑end features without long handoffs.</p><h4>Higher technical ambition, not just more speed</h4><p>Teams take on work that used to be “too expensive”:</p><ul><li>Meaningful test coverage in critical areas.</li><li>Pieces of M2 migration or other large refactors.</li><li>Structural cost savings.</li></ul><p>Incident/action items evolve from “add an alert” to “Let’s improve tests here”.</p><p>Engineers use AI pre-work to explore edge cases and tradeoffs (design rigor).</p><p>Better planning, reasoning, and risk reduction:</p><ul><li>Engineers use AI to think through complex scenarios before building, exploring edge cases, stress-testing assumptions, and comparing multiple approaches, and better understand the pros and cons of each approach</li><li>Design docs become more rigorous as AI is used to challenge thinking and look for gaps, risks, and alternate approaches that the team might not be aware of.</li></ul><h4>AI fluency as mindset, not just tool usage</h4><p>Engineers can explain:</p><ul><li>When they choose a heavier model vs a cheaper one, and why.</li><li>How they use AI to reason about a system they don’t know (finding root causes, understanding hidden costs).</li><li>Where they don’t trust AI (judgment calls, ambiguous product decisions, sensitive code paths).</li></ul><p>In reviews, incidents, and 1:1s, people naturally talk about how AI helped them reduce risk or effort, not just “AI wrote this file.”</p><h4>Better quality and maintainability</h4><p>Code quality is improved through AI-assisted code reviews, security audits, and other code analysis tooling.</p><p>Engineers use AI to identify anti-patterns in the code, performance and cost bottlenecks, and security gaps that would otherwise be difficult to find by hand.</p><p>The code in areas we touch becomes clearer and more consistent over time, <em>not messier</em>.</p><p>Engineers use AI to apply established coding and architecture patterns, so touched code stays consistent and easy to navigate and reason about, regardless of which model or harness is used.</p><p>Engineers use AI to simplify and standardize, especially in refactors and tests, and avoid clever one-off patterns.</p><h3>Outcomes to avoid (what we do not want)</h3><p><strong>⛔ <em>These are anti‑goals and bad incentives we should explicitly avoid in performance, ladders, and messaging.</em></strong></p><h4>Velocity metrics for AI</h4><p>No performance metrics as success metrics for AI adoption like:</p><ul><li>Number of PRs/commits.</li><li>Lines of code added/changed.</li><li>Tokens used or dollars spent on models.</li></ul><p><em>*It’s okay to use them as debugging signals to identify if folks need support, are overloaded, or to ensure onboarding is going smoothly.</em></p><p>No framing like “burn $X of tokens per month or you’re not using AI enough.”</p><p>We don’t want engineers to optimize for “more output” at the expense of quality, safety, and thoughtful judgment.</p><h4>Replacing human judgment with blind trust</h4><p>Engineers should not feel pressured to:</p><ul><li>Ship AI‑generated code they don’t understand.</li><li>Skip thinking about architecture, correctness, or ethics because “AI said so.”</li></ul><p>For high‑risk or complex systems, we should increase the expectation of human understanding, even if AI writes the first draft.</p><p>We want to avoid the world where nobody can be held accountable for the code they committed because “AI wrote it”. Engineers are still <strong>accountable</strong> for their code clarity, code correctness, test coverage, and ability to reason about the code. The human judgment of engineers remains what matters most.</p><h4>Drowning in code reviews</h4><p>We want to avoid the case where teams are just cranking out an untenable amount of code, so much so that humans can’t reasonably keep up with reviewing them (even with AI assistance).</p><h4>Eroding codebase understanding</h4><p>We want to avoid:</p><ul><li>Critical areas where nobody can explain how things work because “AI wrote it.”</li><li>Removing code review and shared understanding entirely, especially on core systems.</li></ul><p>The goal is to move people from “I write every line myself” to “I orchestrate the system and can explain it” and not to “I don’t know what this does.”</p><h4>Security and privacy regressions</h4><p>We want to avoid cases where code is quickly written, without realizing what security or privacy implications it might cause. It’s easy to ship stuff with insane security gaps when there is no human looking at it.</p><h4>Ignoring ethical and environmental concerns</h4><p>We should not:</p><ul><li>Force teams to use vendors they see as misaligned with our values.</li><li>Hand‑wave away questions about environmental impact and model energy usage.</li></ul><p>Where possible, we should prefer vendors whose values align better with ours and be explicit about that.</p><h4>Tool theater and cargo-cult adoption</h4><p>Engineers should not feel pressured to mention AI in updates to look modern, even when it didn’t help.</p><p>Teams should not adopt a tool because leadership is excited about it, only when it actually improves results, quality, speed of learning, or safety.</p><p><em>Since this was published on February 20, we’ve rallied around these as </em><strong><em>outcomes</em></strong><em> we want to see from our AI adoption. There’s a lot of hype around AI. Our north star related to AI adoption is that we will see outcomes from our inputs, and that the humans at Medium can move with the speed and responsiveness of a much larger team, while staying true to what makes the platform worth building.</em></p><p><a href="https://medium.com/u/9b63206eb11c"><em>Brittany Jezouit</em></a><em> (Chief of Staff, Medium) wrote and published a </em><a href="https://medium.com/blog/how-medium-uses-ai-at-work-humans-first-especially-in-the-age-of-ai-44d82069242b"><em>related story about how Medium uses AI internally</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=10891d52a19f" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/outcomes-we-want-to-see-from-ai-at-medium-engineering-10891d52a19f">Outcomes we want to see from AI at Medium Engineering</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How we improved image download sizes on Medium with just four characters]]></title>
            <link>https://medium.engineering/how-we-improved-image-download-sizes-on-medium-with-just-four-characters-9621b0ebb291?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/9621b0ebb291</guid>
            <category><![CDATA[html]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[technology]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[web-development]]></category>
            <dc:creator><![CDATA[Scott Batson]]></dc:creator>
            <pubDate>Mon, 18 May 2026 15:29:29 GMT</pubDate>
            <atom:updated>2026-05-18T15:29:28.348Z</atom:updated>
            <content:encoded><![CDATA[<h4><strong>HTML images just got better, with the subtlest change to your codebase</strong></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4iKaVZY6X13vsL7Ax9SOOg.png" /><figcaption>Photo by <a href="https://www.canva.com/p/adisuyatno/">Adi Suyatno</a></figcaption></figure><p>I know what you’re thinking.</p><blockquote>Scott, I landed on Medium on a small but not mobile-small display and the download chunk related to images in my home feed was like 7% smaller.</blockquote><p>First of all, thank you for noticing.</p><p>Second, what if I told you that you too can improve your total asset download size by adding just four characters to your code base?</p><p>Sounds too good to be true, right? Well, I’m here to tell you, it’s true… after I explain how we got here.</p><h3>The history of images on the web</h3><p>As a web developer, you’ve probably spent a lot of time thinking about and fighting with images.</p><p><a href="https://httparchive.org/reports/state-of-the-web?start=latest#bytesperpage">According to the HTTP Archive</a>, even in today’s modern landscape of “JavaScript everywhere” the majority of most website’s transfer size is images. We’ve moved away from the “hero image on every page” ethos, but <em>still</em> we’re an image-heavy internet.</p><p>When I was getting my start in web development in 2010ish, we were still adapting to a “mobile-friendly” world. The majority of the web was built on WordPress.</p><p>We didn’t have a way back then to dynamically load an optimized image. 3G connections were the primary speed in the US for viewing the internet, so the practice was images “need to work on mobile” regardless of the user’s device.</p><p>For those of us writing Angular and Ember (because React wasn’t a thing yet), you had to legitimately render the correct src based on context you could get from JavaScript.</p><pre>{{#if browserSmal}}<br>  &lt;img src=”image-high-res.jpg” /&gt;<br>{{else}}<br>  &lt;img src=”image-med-res.jpg” /&gt;<br>{{/if}}</pre><p>This is back when we wrote <a href="https://handlebarsjs.com/guide/">handlebars</a> for our markup and the web was filling up with SPAs with no server-side-rendering.</p><p>We did it this way because HTML lacked a way for specifying how we wanted to optimize images.</p><p>Then in 2012, <a href="http://ricg.io/">RICG</a> introduced 2 new specs for image optimization, with 2 different use cases: &lt;picture&gt; elements and srcset.</p><h3>Picture Elements</h3><p>I love the &lt;picture&gt; element. It gives us a lot of control on how we want to display an image based on the information we know with a smart fallback if compatibility fails.</p><pre>&lt;picture&gt;<br>  &lt;source srcset=”logo-dark.png” media=”(prefers-color-scheme: dark)” /&gt;<br>  &lt;source srcset=”logo-light.png” media=”(prefers-color-scheme: light)” /&gt;<br>  &lt;img src=”logo-light.png” alt=”Product logo” /&gt;<br>&lt;/picture&gt;</pre><p>In this example, we can specify which image to display if the user prefers dark mode or light mode. If their browser doesn’t support that check, we specify our default.</p><p>We can add logic for screen dimensions, file types (i.e. support for avif or webp), and more. This can be really useful when we want to display <em>different</em> images based on this information.</p><p>For example, if we want a landscape banner image on desktop but on mobile, we want a narrow portrait. This isn’t “the same image at different resolutions” but “different images based on our layout.”</p><p>&lt;picture&gt; is great when we want a lot of control and have opinions on an image.</p><h3>Srcset</h3><p>While the &lt;picture&gt; element was a prescriptive syntax (lets us tell the browser what to do), srcset and is <em>descriptive</em>. We provide the browser with lots of different dimensions of an identical image (ideally) and <em>it</em> decides when to render them.</p><p>This new element/attribute is for providing more control, versus making images faster (it <em>can</em>, but most devs find using it that way to be overkill given you have to define every breakpoint).</p><p>As web developers, we get no say in what the browser decides here. The algorithms used are intentionally opaque as part of the spec:</p><blockquote>In an <a href="https://infra.spec.whatwg.org/#implementation-defined">implementation-defined</a> manner, choose one <a href="https://html.spec.whatwg.org/multipage/images.html#image-source">image source</a> from sourceSet. Let selectedSource be this choice.</blockquote><blockquote><em>— HTML Standards, 4.8.4.3.7 Selecting an image source</em></blockquote><p>Let’s take a quick aside and talk about <em>why</em> the browser owns this implementation because I think it’s important and interesting.</p><p>First of all, as JavaScript developers, we don’t have any meaningful tooling around connection speed. A browser <em>might </em>have that level of clarity (it varies from browser to browser and OS to OS). So already, we can’t necessarily make the right call for which image to use based on that speed.</p><p>But even if we <em>could</em> we’d be making a vague judgement on what <em>we</em> prefer and not what the user prefers (and not all 3G connections are the same). Then there are additional things the browser knows, like which images have already been cached. If we have the 2x version of an image cached, but our page’s srcset requests the 1x version based on this new layout, which is faster to load? The cached 2x or the smaller 1x that requires a new request?</p><p>So the spec says “let the browser handle this.” It has a lot more context than we do and it would be really annoying to write out crazy conditions like srcset=”foo.jpg 480w unless cache is available?, foo.jpg 720w unless bigger cache available?”</p><p>So we provide the sizes we ideally want and the browser takes care of selecting which one to render based on context. Great. Let the computer do computer things.</p><p>What’s the problem then? Have we handled every optimization case possible?</p><p>Not really…</p><h3>Sizes</h3><p>In addition to srcset we also got the size attribute in 2012ish. It let us define our expected dimension for given images.</p><p>When I first <a href="https://blogs.windows.com/msedgedev/2015/06/08/introducing-srcset-responsive-images-in-microsoft-edge/">read a blog post about this</a>, it made no sense to me. “Isn’t that what srcset does??”</p><p>Again, I’m going to take a step back and look at the problem. When our browser receives HTML from our server, it looks at the img tags and starts fetching those images <em>before the layout is rendered</em>. That’s a really important detail. That means there is no reasonable way that the browser can know how much space an image will take up until after it’s been fetched.</p><p>It begins downloading assets, then it paints, renders, and inserts those assets. If it waited to download until after rendering, you’d get empty images and risk layout shift.</p><p>So, to help the browser make an even more informed decision, you can specify sizes on your images: sizes=”(width &lt;= 600px) 480px, 800px”</p><p>Conceptually, it’s so simple. It makes sense. But when you start getting into practical usages (where you are trying to optimize for lots of scenarios) you start writing these out-of-control syntaxes. Mat Marquis, who helped write the spec for srcset and sizes puts it best:</p><blockquote>Describing the sizes of a flexible image will require far too much calculation across breakpoints. (min-width: 1340px) 257px, (min-width: 1040px) calc(24.64vw — 68px), (min-width: 360px) calc(28.64vw — 17px), 80px is an example from a relatively simple layout, and there’s no way anyone could be expected to write this. I mean, how — from, what, resizing your browser and squinting? Guessing? sizes is one of the few markup patterns that all but require the use of tooling, which the furthest possibly cry from the web’s “open any text editor and you can build a website” ethos</blockquote><blockquote><em>— Mat Marquis, from </em><a href="https://piccalil.li/blog/the-end-of-responsive-images/"><em>“The End of responsive images”</em></a></blockquote><p>And that’s just for images we <em>know</em> about? What about user avatars? Blog post images? Random assets we have in sidebars?</p><p>Just go to the Medium home feed and see how many images load there.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Q4Me7of2L7ZGA7TQ" /><figcaption>Screenshot of my JS console, querying all images on the homepage</figcaption></figure><p>Almost 70 images, and we have a relatively <em>light</em> image-to-text ratio compared to most websites. (We’re a reading and writing company, after all.)</p><p>How can we help the browser make the best decision for what image to load? Well, the solution for that has partially been around for a while: loading=”lazy”</p><p>Most images on your website <em>aren’t</em> necessary for first paint. Many of them aren’t even in the viewport at first. For those, we can skip the “fetch asset before rendering” step by saying “this image should only be loaded when we’re confident the user will need it.”</p><p>For lazy loaded images, the browser should already have the information it needs for the layout. It should know the optimal version of the image it needs, right?</p><p>Well, until a few weeks ago, we had no way of communicating that. Before, we had to write endless breakpoint rules with endless image variants.</p><p>But on March 24th, 2026, we got support in <a href="https://bugs.webkit.org/show_bug.cgi?id=310025">WebKit</a> (a similar one to <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1819581">Gecko</a> which powers Firefox) for adding support of “auto” to sizes on lazy-loaded images.</p><h3>Auto is our motto</h3><p>Essentially, this says to the browser: “For this image, only load it when we need it and based on the layout, connectivity, dimensions, etc., load the most optimized version.”</p><p>We can once again let the browser handle all the heavy lifting of “load the most optimized” image and we actually get to write <em>less</em> code than before.</p><p>The best part? The existing sizes spec tells it to ignore any rule it can’t parse. So you can add “auto” to the beginning of all your lazy loaded images, and if the user has a browser that doesn’t yet support it, the rule will just be ignored.</p><p>This is a rule that is so performant and backwards compatible that WordPress (remember them?) already updated <a href="https://core.trac.wordpress.org/changeset/59008">every instance of lazy loaded images in their codebase to support this.</a></p><p>So if you want an easy-to-implement win that has zero risk of breaking your website, slap an “auto” onto your sizes attributes for lazy loaded images.</p><p>That’s how I improved our image download sizes with just four characters. Okay, technically it was 6 characters since I had to add a comma and a space… but still!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9621b0ebb291" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/how-we-improved-image-download-sizes-on-medium-with-just-four-characters-9621b0ebb291">How we improved image download sizes on Medium with just four characters</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Making AI Write Android Code Our Way: A Practical Guide to Agent Skills]]></title>
            <link>https://medium.engineering/making-ai-write-android-code-our-way-a-practical-guide-to-agent-skills-4e7b085d8e50?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/4e7b085d8e50</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Pierrick CAEN]]></dc:creator>
            <pubDate>Tue, 17 Mar 2026 08:25:04 GMT</pubDate>
            <atom:updated>2026-03-17T08:25:02.968Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n5sxsldnVThq58wvtSSInA.png" /><figcaption>Generated by DALL-E</figcaption></figure><p>Turning knowledge into reusable AI agent instructions for a small, fast-moving team.</p><p>We&#39;re a small Android team at Medium, just a handful of engineers maintaining and evolving the Medium Android app. Our codebase follows Clean Architecture with Kotlin, Jetpack Compose, Hilt, Apollo GraphQL, and a growing number of feature modules. Like most Android teams, we have strong opinions about how code should be structured: where ViewModels get their data, how analytics events flow, how feature flags are checked, what a &quot;new screen&quot; looks like from Fragment to preview function.</p><p>The problem? Those opinions lived in PR review comments, Slack threads, and the heads of engineers who&#39;d been around long enough to know the patterns. When AI coding assistants arrived, they could generate Kotlin code but not <em>our</em> Kotlin code. The output was generic. It missed our conventions, our component library, our testing style.</p><p>Six months ago we started using <a href="https://cursor.com/">Cursor</a> as our companion IDE. What changed the game wasn&#39;t Cursor itself, it was <strong>skills</strong> and <strong>AGENTS.md</strong>: a way to encode our team&#39;s playbook so the AI follows it every time.</p><p>This post walks through what we built, how we structured it, and what impact it&#39;s had.</p><h3><strong>The Foundation: AGENTS.md as Project Context</strong></h3><p>Before skills, we wrote an AGENTS.md file at the root of our Android project. Think of it as a README for the AI, a document that&#39;s automatically loaded into context whenever any Agents works on our code.</p><p>Our AGENTS.md covers:</p><ul><li><strong>Architecture overview</strong>: Module structure (data, domain, design, feature modules), layer responsibilities</li><li><strong>Key patterns</strong>: How we do dependency injection (Hilt), state management (StateFlow + SharedFlow), navigation (centralized Router), repository pattern (Apollo + Result&lt;T&gt;)</li><li><strong>Conventions</strong>: Compose best practices, ViewModel patterns, testing strategy</li><li><strong>Common commands</strong>: Gradle tasks for building, testing, and running Detekt</li></ul><p>This gives Agent baseline awareness of our project. When it generates a ViewModel, it already knows to use @HiltViewModel, StateFlow, and @Immutable sealed interfaces. When it creates a test, it reaches for MockK and Turbine instead of Mockito and LiveData.</p><p>But AGENTS.md is passive context. For multi-step, opinionated workflows, we needed something more structured.</p><h3><strong>Skills: Step-by-Step Playbooks for the AI</strong></h3><p>An Agent skill is a Markdown file (stored in .agents/skills/) that teaches the AI a specific workflow. It&#39;s not a template, it&#39;s closer to a runbook: &quot;here are the files involved, here&#39;s the order of operations, here are the patterns to follow, here&#39;s the checklist to verify.&quot;</p><p>We&#39;ve built <strong>13 skills</strong> so far. They fall into four categories.</p><h4>Scaffolding Skills - &quot;Create This From Scratch&quot;</h4><p>These are the highest-leverage skills. They replace the 30-60 minutes an engineer spends setting up boilerplate for a new screen, module, or layer component.</p><p>create-compose-screen: Our most detailed skill. It walks through creating a ViewModel with assisted injection, listener interfaces (in separate files), a Screen composable with a @VisibleForTesting overload, previews for every state, and test tags. A single prompt like <em>&quot;create a new screen for user notifications&quot;</em> produces 6-8 files that follow our exact patterns.</p><p>The skill specifies structure like two versions of every screen composable:</p><pre>// ViewModel-injecting version<br>@Composable<br>internal fun MyFeatureScreen(<br>    itemId: String,<br>    referrerSource: String,<br>    listener: MyFeatureListener,<br>    viewModel: MyFeatureViewModel = hiltViewModel { factory: MyFeatureViewModel.Factory -&gt;<br>        factory.create(itemId = itemId, referrerSource = referrerSource)<br>    },<br>)<br><br>// @VisibleForTesting version (for previews and tests - no ViewModel dependency)<br>@VisibleForTesting<br>@Composable<br>internal fun MyFeatureScreen(<br>    viewState: MyFeatureViewModel.ViewState,<br>    dialogState: MyFeatureViewModel.DialogState?,<br>    snackbarHostState: SnackbarHostState,<br>    listener: MyFeatureInternalListener,<br>)</pre><p>Without the skill, the AI consistently generates a single composable tightly coupled to the ViewModel, which makes previews and UI tests painful.</p><p>create-feature-module: Handles directory structure, build.gradle.kts with the correct plugins and base script, settings.gradle.kts registration, and app-level dependency wiring.</p><p>create-use-case and create-repository Enforce our Clean Architecture layers. Use cases always use operator fun invoke(), return Result&lt;T&gt;, log with our Logger, and track analytics on success. Repositories use @Singleton, safeExecuteNotNull (our Apollo wrapper), and support FetchPolicy.</p><h4>Migration Skills - &quot;Modernize This Code&quot;</h4><p>We&#39;re in the middle of two long-running migrations, and skills let the AI do the mechanical work.</p><p>material3-migration: Contains an exhaustive mapping table of Material 2 to Material 3 component replacements (60+ components). It covers scaffold changes, LocalMinimumInteractiveComponentSize, theme references, and the subtle naming conventions in our design system (MediumScaffold becomes MediumScaffold3, imports shift from component to component3). Without this skill, the AI would have no way to know that MediumPullRefreshIndicator becomes MediumPullToRefreshBox that&#39;s not in any public documentation.</p><p>compose-viewmodel-migration: Guides migrating screens from the old pattern (Fragment creates ViewModel, passes streams to composable) to the new pattern (composable creates ViewModel via hiltViewModel with assisted injection). It covers the BundleInfo pattern, listener splitting, and the @VisibleForTesting overload.</p><h4>Pattern Enforcement Skills - &quot;Do It the Right Way&quot;</h4><p>Some patterns are subtle enough that even experienced engineers occasionally get them wrong. These skills exist to prevent specific categories of bugs and review comments.</p><p>viewmodel-flags-usage: Our most opinionated skill. Feature flags must be checked <em>once</em> at ViewModel initialization and saved as a private val. The result is passed through ViewState as a boolean. Never check flags in composables. Never recompute in a Flow.</p><pre>// Check once at init, save - screen won&#39;t change during use<br>private val isAddressBookEnabled: Boolean = flags.isEnabled(Flag.ENABLE_ADDRESS_BOOK)</pre><p>source-referrer-tracking: Defines the chain: a screen&#39;s source becomes the next screen&#39;s referrerSource. The skill explains SourceParameter serialization, the convention that source should be the last parameter in ViewState data classes, and the anti-pattern of accidentally passing referrerSource forward instead of source.</p><p>implement-analytics-event: Covers the full lifecycle: proto registration in Wire config, tracker interface in core, default implementation in app, Hilt binding, SourceNames constants, and the reportScreenViewed() pattern with deduplication.</p><h4>Workflow Skills - &quot;Handle This Repetitive Task&quot;</h4><p>add-deeplink: Our deeplink handler is a 1000+ line first-match-wins dispatcher. The skill explains the ordering rules (narrow before wide, fragment matches before path matches), provides five patterns (simple path, path + fragment + auth, regex-based dynamic segments), and specifies the test template including both logged-in and logged-out variants.</p><p>add-medium-uri: Three files must be updated in a specific order (interface, NoOp, default implementation) with naming conventions that vary by URL domain. Small task, but easy to get wrong without the skill.</p><p>check-and-add-translations: Finds missing translations across all modules by diffing values/ against values-X/, then adds them with the correct typography conventions (typographic apostrophe, never escaped).</p><p>write-unit-tests: Defines our testing conventions: backtick test names as human-readable sentences, Given/When/Then structure, MockK annotations, MainDispatcherRule for coroutine testing, Turbine for Flow assertions, Robolectric for Compose UI tests, and always wrapping screens in MediumTheme3.</p><h3><strong>What We Learned</strong></h3><p><strong>Skills are living documents.</strong> We’ve iterated on most skills 3–5 times. The first version of create-compose-screen didn&#39;t mention the listener splitting pattern. The add-deeplink skill originally lacked the SUSI destination rule. Each time we caught a pattern break, we updated the skill.</p><p><strong>Specificity beats generality.</strong> The skills that work best are hyper-specific to our codebase. material3-migration is essentially a lookup table. add-deeplink describes the exact ordering of our handler. These aren&#39;t portable to other projects — and that&#39;s the point.</p><p><strong>Skills compound.</strong> A single feature request might trigger create-feature-module, then create-compose-screen, then create-use-case, then create-repository, then implement-analytics-event, then write-unit-tests. Each skill handles its slice correctly. The AI chains them based on what you ask for.</p><p><strong>Consistency is the real win.</strong> With a small team, the risk isn&#39;t that code is bad it&#39;s that it&#39;s inconsistent. One engineer checks flags in a Flow, another checks at init. Skills eliminate that drift. Every new screen looks structurally identical, regardless of who (or what) wrote it.</p><p><strong>Speed is the visible win.</strong> Setting up a new screen with ViewModel, listeners, composable, previews, test tags, and tests used to take most of a morning. Now it takes a prompt and a review pass.</p><h3><strong>Skills can also be written by an Agent, not just Developers</strong></h3><p>You don&#39;t have to write skills yourself. An Agent can write them for you.</p><p>The process works by having the Agent ask you the right questions, then observe your existing code to draft the skill. Here&#39;s how a typical session looks:</p><blockquote><em>&quot;I want to create a skill for adding a new analytics event. Can you walk me through how you usually do it?&quot;</em></blockquote><p>The Agent asks:</p><ul><li>Which files are involved, and in what order?</li><li>Are there naming conventions to follow?</li><li>What&#39;s the checklist you mentally run before opening a PR?</li><li>Can you point me to a recent PR where you did this correctly?</li></ul><p>That last question is key. <strong>PR examples are the fastest way to ground a skill.</strong> When you share a PR link (or paste the diff), the Agent can reverse-engineer the pattern: what changed, in which files, in what order, and what the code structure looks like. It then drafts the skill as a Markdown runbook, which you review and refine.</p><p>A good prompt to get started:</p><blockquote><em>&quot;Look at this PR: [link]. I want to write an Agent skill that teaches an AI to reproduce this pattern from scratch. Ask me any clarifying questions you need, then write the skill file.&quot;</em></blockquote><p>The bar for a useful skill isn&#39;t perfection on the first try. It&#39;s one less PR review comment next week.</p><h3><strong>How to Start</strong></h3><p>If you&#39;re on an Android team (or any team with strong conventions), here&#39;s how we&#39;d recommend starting:</p><ol><li><strong>Write your </strong>AGENTS.md<strong> first.</strong> Document your architecture, patterns, and conventions. This is the foundation.</li><li><strong>Start with scaffolding skills.</strong> Pick your most boilerplate-heavy task (for us: new screens) and write a skill for it. Include the checklist, the file structure, and code patterns.</li><li><strong>Add migration skills for active migrations.</strong> If you&#39;re migrating from Material 2 to Material 3, from RxJava to Coroutines, or from XML to Compose encode the mapping.</li><li><strong>Encode your review feedback.</strong> Every time you leave the same PR comment twice, consider writing a skill for it.</li><li><strong>Keep skills in your repo.</strong> Ours live in .agents/skills/ and are version-controlled. When patterns change, the skills change with them.</li></ol><h3><strong>What&#39;s Next</strong></h3><p>Our AGENTS.md currently carries a lot of weight. It describes our architecture, patterns, conventions, testing strategy, and common commands all in one file. That worked as a starting point, but it has limits: everything is loaded into context all the time, even when only a fraction is relevant to the task at hand.</p><p>Our next step is breaking AGENTS.md into dedicated Agent rules scoped, file-aware instructions that activate only when relevant. For example:</p><ul><li>A rule for Compose conventions that activates when editing *.kt files under ui/ packages</li><li>A rule for repository patterns that activates when working in data/ directories</li><li>A rule for testing conventions that activates when editing files under src/test/</li><li>A rule for ViewModel patterns (state management, SavedStateHandle, error handling) scoped to ViewModel files</li></ul><p>This is the <strong>natural</strong> evolution: AGENTS.md gives the AI everything upfront, rules give it the right knowledge at the right time. Smaller context windows, more precise output.</p><p>Skills teach the AI how to write code. Commands go further — they automate workflows. One we&#39;re actively working on: a <strong>release diff command</strong> that compares the current release branch to the previous one, summarizes the changelog (new features, bug fixes, migrations), and creates a Linear ticket with the formatted release notes. Today that&#39;s a manual process: someone digs through git log, writes up the changes, copies them into Linear. A command could do it in seconds. We see potential for other commands too, generating weekly team reports from merged PRs, auditing a feature module&#39;s dependency graph, or preparing QA checklists from the diff.</p><p><strong>Other explorations:</strong></p><ul><li>Skills for more complex workflows</li><li>Skills for functional testing</li><li>Skills for Compose screen testing with more sophisticated interaction patterns</li></ul><p>The “bet” we&#39;re making is simple: the value of an AI coding assistant scales with how much of your team&#39;s knowledge you can encode into its context. Skills are how we&#39;re doing that.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4e7b085d8e50" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/making-ai-write-android-code-our-way-a-practical-guide-to-agent-skills-4e7b085d8e50">Making AI Write Android Code Our Way: A Practical Guide to Agent Skills</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[We’re Hiring a Principal Backend Engineer to Shape the Future of Medium]]></title>
            <link>https://medium.engineering/were-hiring-a-principal-backend-engineer-to-shape-the-future-of-medium-a0d7896b3717?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/a0d7896b3717</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[medium]]></category>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[backend]]></category>
            <category><![CDATA[hiring]]></category>
            <dc:creator><![CDATA[Michael Margolis]]></dc:creator>
            <pubDate>Wed, 22 Oct 2025 17:19:00 GMT</pubDate>
            <atom:updated>2025-10-29T18:25:08.051Z</atom:updated>
            <content:encoded><![CDATA[<p>When <a href="https://medium.com/@yipe/why-im-joining-medium-0b0479080e18">I joined Medium</a>, it was because I still believe in the internet as a place for ideas. Not noise, not outrage, but content that connects people through shared curiosity.</p><p>Medium’s mission is to deepen understanding and spread ideas that matter. We’re building the best place for reading and writing online: A space that rewards clarity, authenticity, and craft over clickbait and engagement hacks.</p><p>That vision only works if the technology beneath it is as thoughtful as the writing above it. That’s where <strong>you</strong> come in. <em>Yes, you!</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*v_FWVO8mT1JDGUxb" /><figcaption>Photo by <a href="https://unsplash.com/@yanu?utm_source=medium&amp;utm_medium=referral">Yannick Pulver</a> on <a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral">Unsplash</a></figcaption></figure><h3>The Role</h3><p>We are starting the search for a <a href="https://job-boards.greenhouse.io/medium/jobs/4048233009"><strong>Principal Backend Engineer</strong></a> at Medium, our most senior IC level and a leadership position at the company. In this role, you will lead the evolution of Medium’s core backend, the systems that power reading, writing, and discovery for millions of users every day.</p><p>This is a <strong>deeply</strong> <strong>hands-on</strong>, <strong>high-impact role</strong>. You’ll help <strong>craft the strategy for where we want to be as an engineering organization on the backend</strong>, and be accountable for the <strong>quality, standards, and evolution of the Medium backend platform</strong>.</p><p>You will:</p><ul><li><strong>Partner</strong> with engineering, design, data, and executive leadership to define and deliver the future of Medium.</li><li><strong>Make</strong> our platform not just reliable, but genuinely delightful to build on.</li><li><strong>Modernize</strong> a large, living codebase so our teams can move faster and ship with confidence.</li><li><strong>Lead</strong> through influence by setting standards, reviewing critical code and designs, and mentoring engineers across teams.</li><li><strong>Shape</strong> how we build, ensuring our architecture, practices, and systems are strong, scalable, and aligned with Medium’s long-term goals.</li><li><strong>Balance </strong>vision with execution by building, debugging, and refining systems yourself to model technical excellence and thoughtful engineering craft.</li></ul><p>And you can do this work wherever you do your best thinking, whether that’s a home office, a beachside Airbnb, a rainy coastal cabin in the Pacific Northwest, or your favorite café with great Wi-Fi and even better croissants. This role is <strong>fully remote </strong>in the US<strong>.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*N2DjaEJ0Ffyr5Ysz" /><figcaption>Photo by <a href="https://unsplash.com/@proskurovskiy?utm_source=medium&amp;utm_medium=referral">Volodymyr Proskurovskyi</a> on <a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral">Unsplash</a></figcaption></figure><h3>Why This Role Matters</h3><p>I’ve been lucky enough to work with some truly great Principal Engineers in my career, the kind of people who bend the trajectory of a company. They don’t just write brilliant code, they <strong>change how everyone around them thinks about building</strong>. They spot patterns before they calcify into problems, simplify where others complicate, and bring calm, clear reasoning to complex systems and high-stakes decisions. When they get it right, everyone moves faster, builds better, and understands the system (and the mission) more deeply.</p><p>That’s what makes this role special. It’s not just about scale, it’s about <strong>stewardship</strong>.</p><h3>About You</h3><p>You find joy in making <strong>complex systems feel simple</strong>, in building things that <strong>help others move faster and think deeper</strong>. You leave codebases better than you found them, teams more confident than before you joined them, and products more human in how they behave.</p><p>You’ve <strong>built and scaled</strong> systems that millions rely on, and you’ve learned that reliability isn’t just uptime, it’s <strong>trust</strong>. You’ve wrestled with technical debt, fan-outs, and edge cases galore, and found ways to turn them into leverage, not legacy.</p><p>You lead with curiosity, not control. You ask great questions, create shared understanding, and help others see the system as clearly as you do.</p><p>And through it all, you never forget who it’s for: the writers and readers who come to Medium to make sense of the world.</p><h3>Why Medium</h3><p>To me, Medium has always been a rare and wonderful corner of the internet. A place where people come to slow down, think deeply, reflect, and share real human experiences.</p><p>In an era where misinformation, AI, and bots are reshaping public discourse, it’s <strong>more important than ever to elevate real human voices</strong>. That’s only possible if the technology underneath is <strong>fast, stable, and built for longevity</strong> so we can keep Medium human-centered, sustainable, and ready for what’s next.</p><p>If you’re a builder who loves the elegance of distributed systems and the beauty of human stories, <a href="https://job-boards.greenhouse.io/medium/jobs/4048233009">we’d love to meet you</a>.</p><p>Let’s build a better internet together. One that rewards ideas, not division.</p><p><strong>👉 </strong><a href="https://job-boards.greenhouse.io/medium/jobs/4048233009"><strong>Apply here</strong></a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*93eNUm5UN7tYJayn8iXfeA.jpeg" /><figcaption>Source: <a href="https://www.linkedin.com/posts/medium-com_some-publishing-platforms-refuse-to-moderate-activity-7349463524403757056-QMfB/">Medium</a></figcaption></figure><ul><li><a href="https://medium.com/jobs-at-medium/work-at-medium-959d1a85284e">Work at Medium</a></li><li><a href="https://medium.com/me/following-feed/publications/2817475205d3">Following - Medium Engineering</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a0d7896b3717" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/were-hiring-a-principal-backend-engineer-to-shape-the-future-of-medium-a0d7896b3717">We’re Hiring a Principal Backend Engineer to Shape the Future of Medium</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned]]></title>
            <link>https://medium.engineering/medium-android-app-migrating-from-apollo-kotlin-3-to-4-lessons-learned-ff8d0d861cdb?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/ff8d0d861cdb</guid>
            <category><![CDATA[graphql]]></category>
            <category><![CDATA[apollo-client]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[kotlin]]></category>
            <dc:creator><![CDATA[Pierrick CAEN]]></dc:creator>
            <pubDate>Mon, 06 Oct 2025 08:18:42 GMT</pubDate>
            <atom:updated>2025-10-06T08:21:08.045Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DVZBoA0hHs4SHvqF" /><figcaption>Photo by <a href="https://unsplash.com/@mario?utm_source=medium&amp;utm_medium=referral">Mario Verduzco</a> on <a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral">Unsplash</a></figcaption></figure><h3>Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned</h3><p>In this post, I’ll share my experience migrating the Medium Android app from Apollo Kotlin version 3 to version 4, including the challenges I encountered and how I solved them to improve our GraphQL implementation.</p><h3>Understanding Our Apollo Cache Implementation</h3><p>Before diving into the migration, it’s important to understand how we use Apollo’s cache in the Medium Android app. Our app relies heavily on Apollo’s normalized cache for several critical purposes:</p><p><strong>Performance Optimization</strong>: We use FetchPolicy.CacheFirst as our default strategy, which means we always try to serve data from the cache first before making network requests. This significantly reduces loading times and provides a smooth user experience, especially when users navigate between screens that display similar content.</p><p><strong>Real-time Updates:</strong> We use Apollo’s watch() functionality extensively to observe cache changes and automatically update our UI when data changes. This is particularly useful for features like:</p><ul><li>Live clap counts on posts</li><li>Real-time follower updates</li><li>Post viewed updates</li><li>and more…</li></ul><h3>Starting the Migration</h3><p>The initial plan was straightforward: update Apollo Kotlin from version 3 to 4. The IntelliJ plugin made this process seem simple at first glance.</p><h3>Key Changes in Apollo Kotlin 4</h3><ul><li><strong>Group id / plugin id / package name:</strong> Apollo Kotlin 4 uses a new identifier (com.apollographql.apollo) for its maven group id, Gradle plugin id, and package name. This change from com.apollographql.apollo3 allows running version 4 alongside version 3 if needed. Source: <a href="https://www.apollographql.com/docs/kotlin/migration/4.0">Apollo Kotlin Migration Guide - Group id / plugin id / package name</a></li><li><strong>Exception handling:</strong> Apollo 4 has a new way of handling exceptions. Instead of throwing exceptions directly, they’re now passed through ApolloResponse. Source: <a href="https://www.apollographql.com/docs/kotlin/migration/4.0#fetch-errors-do-not-throw">Apollo Kotlin Migration Guide — Fetch errors do not throw</a></li><li><strong>ApolloCompositeException:</strong> In Apollo Kotlin 3, when both cache and network operations failed with a CacheFirst policy, you’d get an ApolloCompositeException containing both errors. Apollo Kotlin 4 simplifies this by throwing only the primary exception while adding any secondary failures as suppressed exceptions, making error handling more straightforward. Source: <a href="https://www.apollographql.com/docs/kotlin/migration/4.0#apollocompositeexception-is-not-thrown">Apollo Kotlin Migration Guide — ApolloCompositeException is not thrown</a></li></ul><h3>The Challenge: Cache Miss Exceptions</h3><p>After making the initial changes, I encountered a major issue: CacheMissException errors were appearing throughout our UI wherever we used watch(). This was happening because Apollo 4 passes all exceptions to ApolloResponse instead of silently ignoring them when using FetchPolicy.CacheFirst.</p><p>This exposed an underlying issue: our cache configuration wasn’t optimal.</p><h3>Fixing the Cache Implementation</h3><p>Our app was using <a href="https://www.apollographql.com/docs/kotlin/caching/programmatic-ids">Programmatic cache IDs</a>, but the implementation had issues:</p><ul><li>Some IDs were missing from our CacheKeyGenerator</li><li>Some cache key generation logic was incorrect</li><li>These problems led to frequent cache misses</li></ul><h3>Solution: Declarative Cache IDs &amp; __typename on all Operations</h3><p>I decided to switch from Programmatic to <a href="https://www.apollographql.com/docs/kotlin/caching/declarative-ids">Declarative cache IDs</a>, which made it significantly easier to match IDs to types and fields. Here’s how I implemented it:</p><pre># Types<br>extend type Catalog @typePolicy(keyFields: &quot;id&quot;)<br>extend type CatalogViewerEdge @typePolicy(keyFields: &quot;id&quot;)<br>extend type Collection @typePolicy(keyFields: &quot;id&quot;)<br>extend type CollectionViewerEdge @typePolicy(keyFields: &quot;id&quot;)<br>extend type Post @typePolicy(keyFields: &quot;id&quot;)<br>extend type PostViewerEdge @typePolicy(keyFields: &quot;id&quot;)<br>extend type User @typePolicy(keyFields: &quot;id&quot;)<br>extend type UserViewerEdge @typePolicy(keyFields: &quot;id&quot;)<br># etc<br><br># Fields<br>extend type Query @fieldPolicy(forField: &quot;collection&quot;, keyArgs: &quot;id&quot;)<br>extend type Query @fieldPolicy(forField: &quot;post&quot;, keyArgs: &quot;id&quot;)<br>extend type Query @fieldPolicy(forField: &quot;publication&quot;, keyArgs: &quot;id&quot;)<br>extend type Query @fieldPolicy(forField: &quot;user&quot;, keyArgs: &quot;id&quot;)<br># etc</pre><p>To further improve cache hit rates, I added the __typename to all operations by configuring it in the Gradle build file:</p><pre>apollo {<br>    service(&quot;service&quot;) {<br>        addTypename.set(&quot;always&quot;)<br>    }<br>}</pre><h3>Cache Update Extension Functions</h3><p>To further improve our caching logic, I created extension functions for updating cache fragments that make both reading and writing more intuitive:</p><pre>internal suspend inline fun &lt;D : Fragment.Data&gt; ApolloStore.updateCache(<br>    fragment: Fragment&lt;D&gt;,<br>    cacheKey: CacheKey,<br>    customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty,<br>    cacheHeaders: CacheHeaders = CacheHeaders.NONE,<br>    publish: Boolean = true,<br>    crossinline block: (cachedData: D) -&gt; D,<br>): Set&lt;String&gt; {<br>    val cachedFragment = getCachedFragment(<br>        fragment = fragment,<br>        cacheKey = cacheKey,<br>        customScalarAdapters = customScalarAdapters,<br>        cacheHeaders = cacheHeaders,<br>    ) ?: return emptySet()<br><br>    return writeFragment(<br>        fragment = fragment,<br>        cacheKey = cacheKey,<br>        fragmentData = block(cachedFragment),<br>        customScalarAdapters = customScalarAdapters,<br>        cacheHeaders = cacheHeaders,<br>        publish = publish,<br>    )<br>}<br><br>suspend fun &lt;D : Fragment.Data&gt; ApolloStore.getCachedFragment(<br>    fragment: Fragment&lt;D&gt;,<br>    cacheKey: CacheKey,<br>    customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty,<br>    cacheHeaders: CacheHeaders = CacheHeaders.NONE,<br>): D? = runCatching {<br>    readFragment(<br>        fragment = fragment,<br>        cacheKey = cacheKey,<br>        customScalarAdapters = customScalarAdapters,<br>        cacheHeaders = cacheHeaders,<br>    )<br>}<br>.onFailure { e -&gt;<br>    when (e) {<br>        is CacheMissException -&gt; Timber.e(e, &quot;Cache miss on fragment $fragment with cache key $cacheKey.&quot;)<br>        is ApolloException -&gt; Timber.e(e, &quot;Cache read error on fragment $fragment with cache key $cacheKey.&quot;)<br>        else -&gt; Timber.e(e, &quot;Unexpected error while reading fragment $fragment with cache key $cacheKey.&quot;)<br>    }<br>}<br>.getOrNull()</pre><p>These extension functions significantly improved the readability of our cache manipulation code and provided better error handling for cache operations.</p><h3>Testing and Handling Cache Exceptions</h3><p>To ensure our Apollo response handling was correct, I first wrote unit tests for our methods that transform ApolloResponse into Kotlin Result types.</p><pre>@Test<br>fun `safeWatch with CacheMissException and FetchPolicy#CacheFirst should return success flow`() = runTest {<br>    // Given<br>    val requestUuid = Uuid.randomUUID()<br>    val cacheResponse = ApolloResponse.Builder(<br>        operation = IsFollowingCatalogQuery(&quot;ID&quot;),<br>        requestUuid = requestUuid,<br>    )<br>        .exception(CacheMissException(CacheKey(Catalog.type.name, &quot;CATALOG_ID&quot;).toString()))<br>        .build()<br>    val data = IsFollowingCatalogQuery.Data(<br>        catalogById = IsFollowingCatalogQuery.CatalogById(<br>            __typename = Catalog.type.name,<br>            catalogFollowData = CatalogFollowData(<br>		            __typename = Catalog.type.name,<br>		            id = &quot;CATALOG_ID&quot;,<br>		            viewerEdge = CatalogFollowData.ViewerEdge(<br>		                __typename = CatalogViewerEdge.type.name,<br>		                id = &quot;VIEWER_EDGE_ID&quot;,<br>		                isFollowing = true,<br>		            )<br>		        ),<br>        )<br>    )<br>    val networkResponse = ApolloResponse.Builder(<br>        operation = IsFollowingCatalogQuery(&quot;CATALOG_ID&quot;),<br>        requestUuid = requestUuid,<br>    )<br>        .data(data)<br>        .build()<br>    every { mockApolloCall.watch() } returns flowOf(cacheResponse, networkResponse)<br><br>    // When<br>    mockApolloCall.safeWatch(FetchPolicy.CacheFirst) { it }.test {<br>        // Then<br>        val result: Result&lt;IsFollowingCatalogQuery.Data&gt; = awaitItem()<br>        assertTrue(result.isSuccess)<br>        assertEquals(expected = data, actual = result.getOrNull())<br>        assertNull(result.exceptionOrNull())<br>        awaitComplete()<br>        ensureAllEventsConsumed()<br>    }<br>}</pre><p>Then I fixed the exception propagation in our watchers with this approach:</p><ul><li>We added fetchPolicy and refetchPolicy parameters to our watchers with default values. These defaults match those used by the Apollo Kotlin SDK.</li><li>We transform the ApolloResponse into a Result, enabling us to handle either Success or Failure cases.</li><li>If the fetchPolicy is CacheFirst or CacheAndNetwork, we are skipping the CacheMissException, as Network will emit after either a Success or an ApolloNetworkException.</li><li>If the fetchPolicy is NetworkFirst, we are skipping the ApolloNetworkException, as Cache will emit after either a Success or an CacheMissException.</li></ul><pre>inline fun &lt;D : Query.Data, R&gt; ApolloCall&lt;D&gt;.safeWatch(<br>    fetchPolicy: FetchPolicy = FetchPolicy.CacheFirst,<br>    refetchPolicy: FetchPolicy = FetchPolicy.CacheOnly,<br>    crossinline transform: (D) -&gt; R,<br>): Flow&lt;Result&lt;R&gt;&gt; = this<br>    .fetchPolicy(fetchPolicy)<br>    .refetchPolicy(refetchPolicy)<br>    .watch()<br>    .mapNotNull { response -&gt;<br>        val result = response.toResult(transform)<br>        val exception = result.exceptionOrNull()<br><br>        when {<br>            exception is CacheMissException &amp;&amp; fetchPolicy == FetchPolicy.CacheFirst -&gt;<br>                null<br>            exception is CacheMissException &amp;&amp; fetchPolicy == FetchPolicy.CacheAndNetwork -&gt;               <br>                null<br>            exception is ApolloNetworkException &amp;&amp; fetchPolicy == FetchPolicy.NetworkFirst -&gt;<br>                null<br>            else -&gt;<br>                result<br>        }<br>    }</pre><p>The key insight here is that different fetch policies have different fallback strategies, and our exception handling needs to respect these strategies. By filtering out expected exceptions that will be followed by either success or a different type of exception, we ensure that our UI only receives meaningful errors that require user attention.</p><h3>Completing the Migration</h3><p>After resolving these cache-related issues, I was finally able to complete the migration to Apollo 4:</p><ul><li>Replaced executeV3() with execute()</li><li>Updated watch() calls to remove fetchThrows = true</li><li>Fixed ApolloCompositeException handling</li></ul><h3>Additional Improvements: Custom Type Adapters</h3><p>While diving deep into the Apollo documentation, I also discovered we could use type adapters for scalar values. I implemented this for our Currency scalar:</p><pre>import java.util.Currency<br>object CurrencyAdapter : Adapter&lt;Currency&gt; {<br>    override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): Currency =<br>        Currency.getInstance(reader.nextString())<br><br>    override fun toJson(writer: JsonWriter, customScalarAdapters: CustomScalarAdapters, value: Currency) {<br>        writer.value(value.currencyCode)<br>    }<br>}</pre><h3>Future Improvements</h3><h3>HTTP Batching</h3><p><a href="https://www.apollographql.com/docs/kotlin/advanced/query-batching">HTTP batching</a> allows multiple GraphQL operations to be sent in a single HTTP request, reducing network overhead. This is particularly useful for applications that execute multiple queries simultaneously, as it can significantly improve performance by reducing the number of network requests. We are currently using HTTP batching on our Web platform without encountering any issues.</p><h3>Persisted Queries</h3><p><a href="https://www.apollographql.com/docs/kotlin/advanced/persisted-queries">Persisted Queries</a> improve network performance by sending a query hash instead of the full query text. This reduces payload size and can improve security. The server maintains a mapping of hashes to query strings, allowing it to execute the appropriate query when it receives a hash. Note that implementing Persisted Queries requires backend support.</p><h3>Conclusion</h3><p>What began as a simple version upgrade became a comprehensive overhaul of our GraphQL implementation. By switching to Declarative cache IDs, adding __typename to all operations, and properly handling cache exceptions, we&#39;ve significantly improved the cache hit of our Apollo GraphQL integration.</p><p>The key takeaway: when upgrading Apollo Kotlin, be prepared to revisit your caching strategy. The improvements in version 4 expose issues that might have been hidden in version 3, but fixing them leads to a more robust implementation.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ff8d0d861cdb" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/medium-android-app-migrating-from-apollo-kotlin-3-to-4-lessons-learned-ff8d0d861cdb">Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Engineering stories behind the Medium Daily Digest Algorithm: Part 2]]></title>
            <link>https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-2-c977ad0b134f?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/c977ad0b134f</guid>
            <category><![CDATA[database]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <category><![CDATA[bloom-filter]]></category>
            <category><![CDATA[dynamodb]]></category>
            <dc:creator><![CDATA[Raphael Montaud]]></dc:creator>
            <pubDate>Thu, 28 Aug 2025 11:31:36 GMT</pubDate>
            <atom:updated>2025-09-09T15:43:13.996Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mpEOt9tgqfJVgyNYZFT3vg.jpeg" /></figure><h4>How we made our filtering 10x cheaper by removing our Bloom Filters</h4><p>Bloom Filters are great tools to make fast and cheap filtering. They also come with plenty of problems and can easily get expensive and cumbersome. We switched to user-based direct database queries, which made our filtering cheaper and easy to maintain. Here’s the full breakdown of that migration.</p><blockquote><strong>Intro</strong>: This is a 4-part series breaking down improvements to the algorithm behind the Medium’s Daily Digest over the past year. When we started this work, the Digest was suboptimal — and since it’s a huge distribution surface, reaching millions of readers every day, we started working on incremental improvements.</blockquote><blockquote>By the end of these projects, the digest was 10% more likely to convert users to paying members, less expensive to run, more flexible and easier to maintain and it’s now providing higher quality recommendations for all our users, including our “power readers”.</blockquote><blockquote>This is told through the lens of our engineering team tackling a series of challenges one by one. Medium has a small team but we operate on a big scale. We’re working our way through some technical debt and at the same time, striving to provide the best experience for our readers. This is the source of many interesting challenges.</blockquote><blockquote>I hope this series helps you understand how the recommendations algorithm work and can help others who are facing similar technical challenges.</blockquote><p>This is probably the most technical story in the series, but I will keep it as simple as possible and hopefully this is interesting for non-technical readers too.</p><h3>Some Concepts</h3><p>Here’s a little cheat sheet with some concepts you may need to follow along with this story</p><figure><img alt="Hand-drawn cheat sheet explaining Medium’s platform and recommendation system. Shows how Medium curates content through a 3-stage process: Source (pulls stories from various sources), Filter (removes duplicates/already read), and Rank (scores stories to predict user interest). Includes 4 recommendation surfaces: Daily Digest email, Homepage feed, push notifications, and post-reading suggestions called “Recire.”" src="https://cdn-images-1.medium.com/max/1024/1*9V1FpdLmgfQgEc8YDA9xFQ.png" /><figcaption>You may need this to understand the rest of this post</figcaption></figure><h3>Bloom Filters at Medium</h3><p>A lot of the filters I mention in this series are backed by Bloom Filters (I’ve described some of those filtering rules in <a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-1-909a7ca5e807">Part 1</a> if you haven’t read it already). We use Bloom filters to remove stories we think won’t interest readers from their feeds and other recommendations:</p><p><strong>For example:</strong></p><ul><li>our “muted” filter removes all stories from writers that you have muted</li><li>our “read” filter removes all stories that you have already read</li><li>our “presentation” filter removes all stories that have already been presented to you 3 times or more</li></ul><p>These all rely on Bloom Filters</p><h3>So what’s a Bloom Filter?</h3><p>I asked Claude to summarize Bloom filters in a really simple way and it went with a funny analogy I’m going to try here.</p><p>A Bloom filter is like a super-efficient bouncer at a club who has a really good memory but isn’t perfect. It lets you do two things:</p><ol><li>let someone into the club<br> → in code that would be an add(string) function</li><li>lets you ask if someone is in the club. There are two possible answers to this:<br> → “yes, probably”<br> → “no, definitely not”<br> → in code that would be a check(string) --&gt; bool function</li></ol><p>That doesn’t sound super useful like that but we’ll see next that it’s actually kinda well suited for recommendation systems.</p><p>At Medium we’re using it to store information such as “user a read story x” or “user a muted user b”. We add those to the “club” as strings, like read|user_x|story_y . Later on, when we want to know if user x has already read story y, we just ask our “bouncer”: is read|user_x|story_y in the club?</p><figure><img alt="Flow diagram showing Medium’s Bloom Filter system. Two user actions feed into the green Bloom Filter: “User reads a story” adds ‘read|user_a|post_x’ and “User mutes a writer” adds ‘muted|user_a|user_b’. The filter checks if actions are “in da club” and connects to recommendation algorithm for filtering user content." src="https://cdn-images-1.medium.com/max/1024/1*o_zsHWREij_C_OeehtOGNg.png" /><figcaption>How we use Bloom Filters to filter out muted writers and already read stories from user feeds</figcaption></figure><h3>The scale of the filtering</h3><p>What’s nice with Bloom filters is that they are able to store the information very efficiently and they are able to handle big amounts of requests per second. We don’t really need to know more about the inner workings of Bloom filters for this series but you can read more about it <a href="https://systemdesign.one/bloom-filters-explained/">here</a> (and it has some Excalidraw schemas too 🤌).</p><p>When we’re building a feed for a user (the digest for example), we’re sometimes sourcing up to 5000 stories as the initial “shortlist” of stories. This shortlist goes through many different steps and when we’re done, there’s only a handful of stories, ready to be sent in your digest 🙌</p><p>Ideally we should filter out stories as early as possible in the process. If we take the filters I’ve listed above, we’re asking ourselves 3 questions for every story in the short list:</p><ul><li>did the user read this story already?</li><li>is the writer of the story muted by the user?</li><li>was the user presented this story more than 3 times already?</li></ul><p>So that can add up to 15k questions in total for a single user feed, and we process thousands of feeds per second. So that’s more than 15M questions per second. You can see how that can get out of hand and become expensive very quickly, we’re going to need some solid infrastructure to handle this.</p><p>Fortunately our Bloom filter “bouncer” is able to answer an insane amount of questions per second for pretty cheap, which is exactly what we’re looking for.</p><h3>Bloom Filter downsides</h3><p>The biggest downside for us is that even though the Bloom filters are super memory efficient, we overused them so much by adding literally billions of items that they started getting really really big and expensive. Like I mentioned before, there are only two operations you can do on a Bloom filter: add or check. There’s no way to delete any data from the filter (remove people from the club, if we stick with the bouncer analogy). And so the club can only grow in size, there’s no way to perform routines cleanups for information that we don’t need anymore.</p><p>The only way to reduce the size of the club is to start a new club with a new bouncer and then retire the old club and bouncer.</p><p><strong>There are also a few more downsides:</strong></p><ul><li>you can’t list the items that are stored (you can’t get a list of people who are in the club…). So we can’t go and see what’s stored for a given user, this makes it really hard to debug issues with the filters</li><li>you can’t remove anyone from the club, which means that you can’t change your mind. For example if a user “unmutes” a writer, there’s no way to reflect that in the Bloom filter. That writer will be muted forever for that user. Yes, that’s very janky.</li><li>this is derived data. For example the user “mutes” are stored in a proper database, and anytime there is a mute action we need to forward that information to the bloom filter. This is inconvenient and introduces data drift issue and more complexity overall.</li></ul><figure><img alt="Diagram showing mute/unmute workflow with databases and Bloom Filter. Top: “User mutes a writer” updates database with timestamp and adds entry to Bloom Filter (shown as crosshatched “Black Box”). Bottom: “User unmutes a writer” updates database but shows “not possible” arrow to Bloom Filter, illustrating that Bloom Filters can’t remove entries once added." src="https://cdn-images-1.medium.com/max/1024/1*GDwjQOf0c7dHaVoFzo1y8Q.png" /><figcaption>Bloom filters are not databases. You can’t see what’s inside and you can’t remove items</figcaption></figure><p>All in all, there were too many downsides so we decided to explore a different approach.</p><h3>Replacing the “Muted Filter”</h3><p>When we build the feed for a given user, it happens in real time. It can be when you load the Medium homepage, or when we trigger the daily digest generation. We’re typically in the following situation:</p><ul><li>we have a shortlist of 1000 stories</li><li>we want to filter out the stories from writers that are muted by the user</li></ul><p><strong>There are two ways to go about this:</strong></p><p><strong>A. perform a lookup in the database table</strong> that keeps track of muted writers for the 1000 (userID, writerID) pairs and check the mutedAt attribute</p><figure><img alt="Database lookup diagram titled “Pairwise Lookups on Database” showing inefficiency of checking 1000 user-writer pairs individually. Database table shows userID, writerID, and mutedAt columns with sample data. Arrow shows recommendation algorithm requesting all pairs (user_a, writer_1) through (user_a, writer_1000) separately, demonstrating performance bottleneck." src="https://cdn-images-1.medium.com/max/1024/1*O21BbwJT2M1LZFePFSxHYA.png" /><figcaption>Approach A. For each pair we do a database lookup</figcaption></figure><p>This is extremely expensive which is why we have to introduce a Bloom filter, where lookups are faster and cheaper</p><figure><img alt="diagram titled “Pairwise Lookups with Bloom Filter”. Recommendation algorithm queries Bloom Filter with 1000 muted user-writer pairs like ‘muted|user_a|writer_1’ through ‘muted|user_a|writer_1000’ asking “Are those ‘in da club’?”" src="https://cdn-images-1.medium.com/max/1024/1*DfgSJkxOj5GoiuK4DHbHHw.png" /><figcaption>Approach A. but with a Bloom filter</figcaption></figure><p><strong>B. or fetch ALL of the muted writers for the current user</strong> and then cross-reference that with your shortlist of stories</p><figure><img alt="Database query diagram titled “With Direct Database query” showing average of ~1 database items read. Database table with userID, writerID, mutedAt columns. Arrow shows recommendation algorithm requesting “Get all writers muted by user_a” and database responding with “writer_1, writer_5”. Single efficient query instead of multiple lookups." src="https://cdn-images-1.medium.com/max/1024/1*ZlHYC_dAifZy8aj94CENRA.png" /><figcaption>Approach B. fetch all muted writers and then cross-reference with the shortlist</figcaption></figure><p>Approach B has a massive advantage: <strong>most users do not mute anyone</strong>. So on average, we’re reading a very small amount of data from the database. If we were to do that with dynamoDB, B is a Query that retrieves on average less than 1 item from the DB for each feed. <strong>This is very cheap and fast</strong>. For “power muters” — users who mute massive amounts of writers, in the thousands — we can still handle this in real time although with higher latencies.</p><p>So this approach immediately obliterates the need for a Bloom filter. We have a fast, cheap, reliable way to filter out muted writers. We can also use our “ground truth” database directly, no need for derived data with all the headaches this involves.</p><p>So that’s one less Bloom filter! Let’s move on to the next one.</p><h3>Replacing the “Presentation Filter”</h3><p>The question becomes a bit trickier when we look at the “presentation” filter. We’re typically in a situation where:</p><ul><li>we have a shortlist of 1000 stories</li><li>we need to remove all the stories that were presented to the user in a feed (in the past) 3 times or more</li></ul><figure><img alt="Diagram showing Medium feed presentation tracking. Left side shows user feed with three articles (resumes, travel, reading topics) labeled as post_a, post_b, post_d. Arrows show each presentation increments a counter (+1). Right side shows “Presentation Counter” tracking counts for each user-post pair, connected to recommendation algorithm applying “Presentation Filter” to exclude posts shown 3+ times." src="https://cdn-images-1.medium.com/max/1024/1*Ap1enCqRVv1ofHoXziOq6Q.png" /><figcaption>We keep track of how many times posts were presented to a user in a feed. When we build a new feed we make sure to exclude stories that were already presented several times</figcaption></figure><p>We’re currently doing that filtering using a Bloom filter. But you might wonder how we can even do that with Bloom filters. Remember there’s only two operations you can do with a bloom filter:</p><ul><li>add someone to the club</li><li>ask if someone is in the club</li></ul><p>Bloom filters were not built to maintain counters, only true / false information. So we have to hack our way around it by layering them. In terms of clubs and bouncers, it’s like we have a big festival with a bouncer. Inside the festival there’s a private club with another bouncer. And inside that private club there’s a VIP zone with another bouncer… Ultimately we only want to know if someone is inside the VIP zone, so we only need to ask the VIP bouncer. But we still need the two other bouncers to keep track of who’s eligible to get in the VIP zone…</p><p>Going back to Bloom filters: we use 3 layers of Bloom filters, on top of each other, with each one encoding the information “post was presented to user x times”</p><figure><img alt="Flowchart titled “Implementing a counter with Bloom Filters” showing how multiple Bloom Filters track presentation frequency. When post is presented to user, system checks three Bloom Filters: A (≥1 presentation), B (≥2 presentations), C (≥3 presentations). Flow shows: check Filter A, if yes check Filter B, if no add to Filter C. Each filter represents different presentation count thresholds." src="https://cdn-images-1.medium.com/max/1024/1*OAtMCmXPd0Me5A6Bd-gyRQ.png" /><figcaption>How we maintain counters with bloom filters. An event listener updates the bloom filters to maintain the counter when a post presentation happens. That’s just to count to 3. Imagine if you need to count to 10…</figcaption></figure><p>From the recommendations algorithm’s perspective it’s fairly simple, we just lookup the (user, post) pairs in the Bloom filter that encodes the “3” value of the counter (we just ask the VIP bouncer)</p><figure><img alt="Diagram titled “Presentation Filter with Bloom Filter” showing recommendation algorithm querying Bloom Filter C with user-post pairs (user_a, post_1) through (user_a, post_1000) asking “Are those ‘in da club’?” Bloom Filter C contains posts presented at least three times to the user, enabling efficient filtering during recommendation process." src="https://cdn-images-1.medium.com/max/1024/1*4UDl9CUje2EgrgGaM4pruw.png" /></figure><blockquote>NB: that’s the implementation in place at Medium. I don’t know what went into consideration when building it this way. But FIY there are other (probably better) ways to build counters with Bloom Filters. <a href="https://systemdesign.one/bloom-filters-explained/#counting-bloom-filter">It’s possible to use bit-arrays for example</a>.</blockquote><p><strong>So that’s for the Bloom filter implementation. Now how can we handle that differently?</strong></p><p>The user-based approach involves fetching all of the “presentation history” of a user (ie all of the posts that were presented to the user in a feed before) and then going over that list to compute presentation counts for each story.</p><figure><img alt="Direct Database Query diagram showing flow from database table (userID, postID, presentedAt columns) through query “get all presentations for user_a” to list of posts (post_1, post_4, post_13, post_3), then counting to create totals (post_1: 1, post_4: 2, post_3: 6), finally applying threshold to filter out posts that should be filtered." src="https://cdn-images-1.medium.com/max/1024/1*kiKSxLxxpjt9LOcrwKVBgA.png" /></figure><p>That’s a little bit more complex than the “muted filter”, because:</p><ul><li>a lot of users have really massive presentation histories, in the tens of thousands or even bigger. Those are readers who come to Medium every day and are exposed to many recommendations</li><li>on average, users have a lot of posts in their presentation histories. Typically less than 100 though. That’s because this includes many less engaged users who only came a handful of times on Medium, and they bring the average down.</li></ul><p>So the average cost is going to be higher than for the muted filter because we’re going to retrieve more data from the database <strong>AND</strong> some users have such big presentation histories that it’s not possible to fetch it in real time. So how can we tackle that?</p><p>Here, we’re saved by the fact that this filtering rule is not a hard requirement. Nothing says that “3 presentations is the absolute max” for a given (user, post). We built this rule to make feeds more diverse and less repetitive. We chose 3 as a reasonable default, but it’s okay if in some cases we reach higher counts.</p><p>So we can go with a “best effort” strategy here. We fetch the most recent 5k presentations for the user and simply act as if everything before that never existed. DynamoDB queries let us fetch thousands of items quickly, in the ~100ms. So that simple solution is very acceptable in terms of functionality and latencies. There are scenarios where the filter will not be doing its job properly, but they’re limited to certain edge cases with minimal impact on the user.</p><p>We tested this approach and found that the costs were reasonable. This solution gives us more flexibility, for instance we can now easily control and play with the maximum number of presentations (is 3 the right threshold? we’ll see that in <a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-3-6fbf1512e6e6">Part 3</a>). Down the line that will lead to a better experience for users. Now that this filter has been replaced, let’s move on to the final filter, which was the most challenging to migrate.</p><h3>Replacing the “Read Filter”</h3><p>Here’s what we’re trying to do:</p><ul><li>we have a shortlist of 1000 stories</li><li>we need to remove all the stories that the user has already read</li></ul><p>Very similar to the “Presentation Filter”, the user-based approach for this filter involves fetching the entire reading history of the user. We’re in the same situation where most of the time the user’s reading history is relatively small, with less than 100 posts on average. But we do have some users with massive reading histories.</p><blockquote>Fun fact, one of or top readers is our very own Harris Sockel who was in charge of the Medium Newsletter, and had 22k stories in his reading history just for 2024!</blockquote><p>This time, filtering already read posts is a hard requirement of the recommendations algorithm, no way around it. We don’t want to send you an email or a push notification about a post you’ve already read.</p><p><strong>So what do we do?</strong> For this filter specifically we decided to do the filtering in two stages. Very early in the feed building process, we fetch the most recent posts in the user’s reading history (the last 5k posts you’ve read). For the vast majority of users, this will capture their entire reading history. And we use that to filter out already read posts from the feed (at this point, we can have up to 5k posts in the feed). But that’s not enough, we need to guarantee that there are no read posts in the final results for all users.</p><p>So to explain this further, we’ll need to go into our recommendation algorithm in a little bit more detail:</p><figure><img alt="Medium feed creation flowchart titled “Creating a Feed” showing process from multiple sources (followed writers, followed pubs, deep retrieval model) flowing through Source, Early Filter, Aggregate, Rank, and Final Filter stages. Side annotations show “Up to 5k posts” after Source, “Up to 1k posts” after Aggregate, and “About 10 posts” after Final Filter, demonstrating progressive narrowing." src="https://cdn-images-1.medium.com/max/1024/1*DvOB1kih8g6xfTl-7tQ1ZA.png" /><figcaption>A more detailed view of the recommendations algorithm</figcaption></figure><p>You can see that there are two different filtering steps. What we did is that we split out our “Read Filter” into two different implementations and we added each one at a different step:</p><figure><img alt="Two Stage Filtering diagram showing Early Filter and Final Filter stages. Early Filter shows post shortlist being filtered by “Fetch Already Read Posts” to create filtered shortlist, reducing from “Up to 5k posts.” Final Filter shows similar process with “Fetch User-Post Read State” filtering posts, ending with “About 10 posts.” Demonstrates efficient filtering at different stages." src="https://cdn-images-1.medium.com/max/1024/1*Vi7jAHinbo-yqrRq6uprMw.png" /></figure><p>We’re doing user-based queries in the early filtering step. This is nice because costs do not depend on the number of posts in the shortlist. Once we’re down to only a few posts, we can perform pairwise (user, post) lookups.</p><p>This solution works really well and doesn’t add too much complexity to the recs logic. This did require some gymnastics to maintain the requirements while keeping costs, recs performance and latencies under control. But this is much easier to control and debug than the previous implementation with Bloom Filters. It also allows to support things like clearing the reading history for a given user (you can do that from <a href="https://medium.com/me/lists/reading-history">this page</a> to get a “recs fresh start” on Medium). This is a functionality that didn’t work well with the Bloom Filter implementation.</p><h3>Conclusion</h3><p>All in all we were able to get rid of our Bloom filters entirely, resulting in big cost savings. <strong>The new implementation is ten times cheaper than what our Bloom filters were costing us.</strong></p><p>This isn’t necessarily a fair comparison since our Bloom filters had grown out of proportion. If we had migrated from scratch to a new Bloom instances, the Bloom filter implementation might have been cheaper. It was hard to evaluate it in our situation because of the way we built our Bloom filters. We used a single instance and string prefixes to manage all of the different filters.</p><p>If we go back to our club and bouncer analogy, you can see it that way: let’s say there’s different crowds that go to the club, techno lovers, jazz enthusiasts, disco heads…</p><p>A. you can build a single club that hosts everyone. A single bouncer has the responsibility to memorize all the people in the club</p><p>B. or you can build one club for each crowd, each one with a different bouncer</p><p>If one day you don’t care about jazz enthusiasts anymore, with B. you can just retire the jazz club and it’s bouncer. But with A. you still need the bouncer for the other crowds, so you can’t retire the bouncer and it’s still holding the information about all the jazz enthusiasts that entered the club. Down the line you might be paying for a club that’s bigger than you need with approach A. <strong>With approach B. you pay exactly for what you need!</strong></p><p><strong>With Bloom filters it looks like this:</strong></p><figure><img alt="Comparison of Single vs Multiple Bloom Instance architectures. Top shows one Bloom Filter receiving both read and muted data, queried by recommendation algorithm with a `read` or `muted` prefix. Bottom shows separate “Read” and “Muted” Bloom Filters. No need for string prefixes" src="https://cdn-images-1.medium.com/max/1024/1*AdT_565BROXqhN0LTVwhPA.png" /><figcaption>Decoupling filters into separate instances is probably much easier to maintain. When you stop using a filter you can just delete the instance. Our pattern of a single monolith Bloom instance forced us to keep paying until we had migrated all of the filters to newer instances (or to another implementation).</figcaption></figure><p>We were working with a single gigantic Bloom Filter, this meant that we weren’t able to retire anything and our Bloom Instance was ever-growing.</p><p>The bottom line is that this project helped us get our costs down and migrate to newer, more flexible implementations that are much easier to maintain. With our newer implementations:</p><ul><li>costs are easy to understand and are under control</li><li>filtering costs don’t depend on the initial size of the feed “shortlist”</li><li>there’s no need to maintain derived data</li></ul><p>This also laid the groundwork for experiments on our filtering rules that we’ll see in our <a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-3-6fbf1512e6e6"><strong>Part 3: Hard vs Soft Filtering and how this applies to Medium’s Recommendation System</strong></a><strong>.</strong></p><p>Thank you for reading this serires, you can stay tuned for the next installments of this series by following the <a href="https://medium.engineering/">Medium Eng Blog</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c977ad0b134f" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-2-c977ad0b134f">Engineering stories behind the Medium Daily Digest Algorithm: Part 2</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Engineering stories behind the Medium Daily Digest Algorithm: Part 1]]></title>
            <link>https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-1-909a7ca5e807?source=rss----2817475205d3---4</link>
            <guid isPermaLink="false">https://medium.com/p/909a7ca5e807</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Raphael Montaud]]></dc:creator>
            <pubDate>Tue, 26 Aug 2025 11:31:37 GMT</pubDate>
            <atom:updated>2025-09-04T17:39:21.452Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OJipelgT4x24BzI4gtjFIQ.jpeg" /></figure><h4>How we made our email story recommendations better</h4><p>In this Part 1, you’ll understand how we improved one of the main ways our users are exposed to our product and how that led to <strong>a massive 7% increase on the average reading time</strong> for the digest users.</p><blockquote><strong>Intro</strong>: This is a 4-part series breaking down improvements to the algorithm behind the Medium’s Daily Digest over the past year. When we started this work, the Digest was suboptimal — and since it’s a huge distribution surface, reaching millions of readers every day, we started working on incremental improvements.</blockquote><blockquote>By the end of these projects, the digest was 10% more likely to convert users to paying members, less expensive to run, more flexible and easier to maintain and it’s now providing higher quality recommendations for all our users, including our “power readers”.</blockquote><blockquote>This is told through the lens of our engineering team tackling a series of challenges one by one. Medium has a small team but we operate on a big scale. We’re working our way through some technical debt and at the same time, striving to provide the best experience for our readers. This is the source of many interesting challenges.</blockquote><blockquote>I hope this series helps you understand how the recommendations algorithm work and can help others who are facing similar technical challenges.</blockquote><h3>Some Concepts</h3><p>Here’s a little cheat sheet with some concepts you may need to follow along with this story</p><figure><img alt="Hand-drawn cheat sheet explaining Medium’s platform and recommendation system. Shows how Medium builds content recommendations through a 3-stage process: Source (pulls stories from various sources), Filter (removes duplicates/already read), and Rank (scores stories to predict user interest). Includes 4 recommendation surfaces: Daily Digest email, Homepage feed, push notifications, and post-reading suggestions called “Recire.”" src="https://cdn-images-1.medium.com/max/1024/1*9V1FpdLmgfQgEc8YDA9xFQ.png" /><figcaption>You may need this to understand the rest of this post</figcaption></figure><h3>The Discovery</h3><p>A little while back, Leigh, our Machine Learning Engineer and model training guru, started noticing something weird. The recommended stories in his digest were consistently not great matches for his reading profile. At the same time, the recommended stories in his homepage feed (what we call the “For You” feed) were consistently very well targeted.</p><figure><img alt="Side-by-side comparison of Leigh’s Medium feeds. Left shows “For You” feed with AI/recommendation systems stories (generative AI color schemes, Pinterest’s text-to-SQL, candidate ranking models, data team roles). Right shows Daily Digest email with broader data science topics (AI talent, workplace advice, Simpson’s Paradox, Nobel Prize decisions). Demonstrates personalized vs. curated content differences." src="https://cdn-images-1.medium.com/max/1024/1*tXfCC3EG_e14-cx-y3UzCg.png" /></figure><p>This was a bit puzzling and unexpected. Those two recommendation surfaces rely on exactly the same algorithm. We source stories the same way, and we rank them using the same model and features. The only big difference lies in the filtering step.</p><h3>The Investigation</h3><p>So at this point, we thought maybe this is a bug or maybe there’s something in the filtering step that’s amok specifically for the digest. We started digging into the different filters we use for the digest vs the ones we use for the homepage feed. We have many different filtering rules. For example the “Filter Read” simply filters out all the stories that the user has already read. This one is applied on most recommendation surfaces. But some other filters are specific to certain recommendation surfaces:</p><figure><img alt="Comparison of Medium’s filtering systems. Left side shows “Homepage Specific Filters” with one “Presentation Filter” that removes posts shown three times prior. Right side shows “Digest Specific Filters” with three filters: Digest Title Filter (excludes posts used in previous email subjects), Sent in Opened Digest Filter (excludes posts from opened emails), and Digest Backoff Filter (excludes posts from last 7 days’ digests)." src="https://cdn-images-1.medium.com/max/1024/1*3GOqIEAFNJd_ZBAVKaX2HA.png" /><figcaption>Filtering rules differ between the Homepage Feed and the Digest</figcaption></figure><p>One of the reasons why those two surfaces use different filtering is because the reporting does not work the same:</p><ul><li>on the homepage we can track exactly what posts were presented to the user</li><li>with the digest email we have access to less information:<br> → we know if the email was opened (thanks to a tracking pixel, explained below)<br> → we know what posts we sent in a given digest<br> → we know which posts were clicked in a given digest</li></ul><p>Looking at this, we started realizing that the filtering applied to the digest is probably too aggressive. Every day, the algorithm sources the best 15 stories on the entire platform for the reader. But as soon as we detect an email was opened then we will never send the posts it contains again to that reader. That can be a bummer in some situations, for example:</p><ul><li>the user opens the email but doesn’t scroll past the top 5 stories</li><li>all the stories in that digest will never be sent again, although 10 of them didn’t even get a chance to be presented to the reader</li></ul><figure><img alt="Diagram showing Medium’s digest story selection over time. Day 1: top-ranked stories (1–15) go to Day 1 Digest, with lower-ranked stories available. Day 4: top three story groups are crossed out (filtered), so Day 4 Digest uses stories ranked 45–60. Shows how filtering prevents repetition and pushes digests to use progressively lower-ranked content over consecutive days." src="https://cdn-images-1.medium.com/max/1024/1*cuK8iqlKupWijKcow5EtPQ.png" /><figcaption>Illustration to show how we quickly burn through the best stories for a given user if we detect that they open their daily digest emails. After a few weeks the recommendations are less and less relevant to the user as there are fewer and fewer eligible stories for their digest. This is a simplification, the recommendations are not stale and there’s new supply coming in every day, but not necessarily enough to compensate what we’re burning through</figcaption></figure><p>Having worked on email open tracking before, we also knew that there were other elements at play here and that are potentially making things even worse.</p><h3>Apple Mail Privacy Protection</h3><p>In 2021, Apple started Mail Privacy Protection. The idea was to better protect user data and to prevent email open tracking.</p><p>When we send the digest, we also send a pixel tracker in the email. That’s a tiny image URL that we send as part of the mail content and when the email is loaded, the email client has to fetch this image. The URL is engineered in a way that when it’s loaded, we know exactly which email loaded it and so we can record the email as “opened”.</p><figure><img alt="Email tracking diagram showing flow from sender to user. When user opens tracking email, a tiny 1x1 pixel tracking image loads from the tracking server, sending “email was opened” data back to sender. Illustrates how invisible tracking pixels monitor email engagement" src="https://cdn-images-1.medium.com/max/1024/1*orhtPFsZV-Cze_0VOUO8Vg.png" /><figcaption>Tracking pixels are used by email senders to know if a user has opened an email</figcaption></figure><p>Apple’s Mail Privacy Protection has completely destroyed this concept. They are now preloading all your emails directly from their servers, and your email client communicates only with the Apple servers. From our point of view, it means that pretty much all the emails from Apple users are marked as “opened”.</p><figure><img alt="Postmark diagram showing Apple Mail Privacy Protection. Email flows from sender to Apple’s proxy server, then to recipient. When recipient opens email, click and open data goes to Apple’s proxy instead of back to sender. Bottom text reads “Recipient activity is invisible to the sender,” illustrating how Apple blocks email tracking." src="https://cdn-images-1.medium.com/max/1024/1*Oist485PgypueAPxatOQFQ.png" /></figure><p>So a lot of our “email open events” are actually “remote servers opening your email for you so that no one can tell if you actually opened it or not” events. We’re still not sure exactly about the exact figures but several estimates suggest that 50 to 80% of our “email open events” are fake.</p><p>That means a lot of our email open events are “fake news”. And those 15 stories that we carefully selected for you will never be eligible for your digest again — even if a lot of user didn’t actually open the email.</p><p>So it does look like we’re filtering way too aggressively. At this point we were confident that this filtering rule was the culprit and that it’s responsible for degrading the quality of the recommendations in the digest. Now we just need to come up with a plan to fix it.</p><h3>The Plan</h3><p>We went over the filtering rules in place and decided we’d modify a few things:</p><figure><img alt="Before/after comparison of Medium’s digest filtering. Left shows three filters: Digest Title Filter (unchanged), Sent in Opened Digest Filter (red, removes posts from opened digests), and Digest Backoff Filter (filters posts from last 7 days). Right shows updated filters with red filter removed and backoff reduced from 7 to 4 days. Labels show “removed” and “modified.”" src="https://cdn-images-1.medium.com/max/1024/1*YKPx5kU-FQq14-Ee68KtPg.png" /><figcaption>We removed the “Sent in Opened Digest Filter” and scaled down the backoff logic from 7 to 4 days</figcaption></figure><p>This is now a much less aggressive setup. It means that there will be more repetition in digests, the hope is that the best stories are able to make their way to the user and that they don’t get “disqualified forever” too early. Several things are in place to ensure that the digests are not too repetitive and evolve over time:</p><figure><img alt="Cross-digest diversification diagram with two sections. Left shows “Filtering Rules”: Digest Title Filter (excludes posts used in previous email subjects) and Digest Backoff Filter (excludes posts from last 4 days’ digests). Right shows “Organic Evolution of recs”: Newly Published Stories and User Profile Evolution explaining how changing content and evolving user preferences naturally diversify recommendations." src="https://cdn-images-1.medium.com/max/1024/1*qYR-qNlFJ0nJ-3g_YT9yKA.png" /><figcaption>We expect those filtering rules and organic evolution of recs to bring diversification between the daily digests</figcaption></figure><figure><img alt="Diagram showing Medium digest story eligibility rules. “Digest Stories” box contains two items: “rank 1 story” with arrow pointing to “Never eligible again for the digest” and “rank 2 to 15 stories” with arrow pointing to “Not eligible for the next 4 days.” Shows how top story gets permanent exclusion while others get temporary 4-day exclusion." src="https://cdn-images-1.medium.com/max/1024/1*CrHn-hhmwjeTbiTSCAxs8g.png" /><figcaption>A digest contains 15 stories. The first one is used as the email subject, which is why it’s not eligible for the digest anymore</figcaption></figure><p>We were still a little bit wary about potential repetitiveness of digests. We wondered if users were going to notice and complain about that, so we gave a heads-up to the support team to be on the lookout for such complaints</p><p>We put that to the trial in an A/B test and got incredible results very quickly.</p><p><strong>Users in the experiment:</strong></p><ul><li>were <strong>10% more likely to convert</strong> to paying members</li><li><strong>read 7% more</strong> on Medium</li></ul><p><strong>A massive win for Medium!</strong></p><p>Readers didn’t seem to notice any repetitiveness in the digests, so we decided to ship without thinking too much about it.</p><p>We were so happy about the results that we immediately went on the hunt for more filtering rules that we could tweak.</p><p>We first had to dig into the implementation of our filters (CF <a href="https://medium.com/medium-eng/engineering-stories-behind-the-medium-daily-digest-algorithm-part-2-c977ad0b134f">Part 2: How we made our filtering 10x cheaper by removing our Bloom Filters</a>), before we could start making more changes (CF <a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-3-6fbf1512e6e6">Part 3: “Hard vs Soft Filtering” and how this applies to Medium’s Recommendation System</a> and <a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-4-ec7136f21acd">Part 4: Cross-Digest diversification</a>)</p><h3>Some Final Thoughts</h3><p>So it looks like the Apple Mail Privacy Program has been affecting our recommendation systems since 2021. Not all Apple Mail users were in the program initially and the number of users in the program has been scaling up over the years as well as the blast radius on Digest users. My recommendation to you, if you have any features that rely on email open tracking in your product, is to <strong>immediately audit the potential effects of those “fake email opens”</strong>.</p><blockquote>To be clear we are absolutely in favor of user privacy measures. The Apple privacy program has forced us to re-evaluate the way we build our recommendations in a less intrusive way, which is a good thing.</blockquote><p>We recently made the <a href="https://medium.com/blog/the-email-digest-is-how-millions-of-medium-readers-find-stories-now-weve-brought-it-to-the-app-c38f08463014">daily digest available in the Medium App</a>, this was a much requested feature by our users. On top of that this is an opportunity to get better tracking than what we do in the email clients. Down the line this could help us improve the recommendations we put in those digests.</p><p>Thank for reading this series, you can stay tuned for the next installments of this series by following the <a href="https://medium.engineering/">Medium Eng Blog</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=909a7ca5e807" width="1" height="1" alt=""><hr><p><a href="https://medium.engineering/engineering-stories-behind-the-medium-daily-digest-algorithm-part-1-909a7ca5e807">Engineering stories behind the Medium Daily Digest Algorithm: Part 1</a> was originally published in <a href="https://medium.engineering">Medium Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>