Exact Counts, Lazy Heights, Unpaused Isolates · Morning Gist
Saturday, October 105 stories · 16 min
Exact Counts, Lazy Heights, Unpaused Isolates
Today's posts cover a faster Postgres search index, a huge diff that still scrolls smoothly, Java tooling built on modules, and profiling production code without pausing it.
Picked from 55 new articles across 34 sources · arrow keys for other days
Today in 30 seconds
30s left
PlanetScale's TIN search index identifies documents by physical row location, turning search into bitmap operations. PlanetScale's own benchmarks show it beating ParadeDB 8–57×. story 4 ↓
On TIN, PlanetScale's Hacker News search demo gets exact match counts in milliseconds, replacing a 3.6-second estimate, on hardware costing one-eighth as much. story 1 ↓
GitHub keeps million-line pull requests scrolling smoothly by tracking fixed-height code rows separately and measuring variable-height comments only when needed. story 2 ↓
Netflix's "ja" makes module-info.java the whole project file. Libraries must declare the runtime access they need instead of relying on class-path workarounds. story 3 ↓
Cloudflare Workers now offers on-demand CPU and memory profiling, sampling live V8 isolates every millisecond while production traffic keeps running. story 5 ↓
Deno's team is joining Cloudflare to make Workers and Durable Objects easier to self-host, which matters if you're worried about lock-in.
1/5PlanetScale· Patrick Reynolds· Sep 23
PlanetScale rebuilds a Hacker News search demo on its TIN Postgres search
PlanetScale forked a Hacker News search app built on Neon's Postgres and moved it to TIN, its own Postgres search extension. The vendor reports exact result counts in milliseconds, new search features, and one-eighth the monthly cost.
Why it matters · It shows where stock Postgres full-text search struggles (ranking and counting large match sets, stop words) and what a faster index lets an application stop doing.
Know this first
Context from the editor, not the article · 28 sec
1 · Postgres full-text search
Postgres splits text into normalized words (a tsvector), indexes them with a GIN inverted index, and ranks matches with ts_rank. Finding matches is quick. Ranking or counting a very large match set is slow, because every matching row has to be visited.
2 · Stop words
Search configurations usually drop around a hundred very common words such as "the", "to" and "be". This keeps indexes small and fast, but a query made up only of those words matches nothing.
3 · The players
Neon is a serverless Postgres service that Databricks now sells as Lakebase. PlanetScale is a managed database company. TIN is PlanetScale's full-text search for Postgres. This post doesn't describe how TIN works inside.
2/5GitHub Engineering· Alberto Gimeno· Sep 23
How GitHub kept a million-line pull request with 400 comments scrolling smoothly
GitHub rebuilt the pull request view in its Copilot desktop app. Code rows still use fixed, precomputed heights. Comments are measured lazily near the screen, and every height correction is pinned to what the reader is looking at.
Why it matters · Any list where some items have fixed heights and others change after they render runs into these problems: feedback loops, scroll jumps, and checks that your own code accidentally triggers.
Know this first
Context from the editor, not the article · 35 sec
1 · List virtualization
Pages slow to a crawl with millions of elements. Virtualization mounts only the rows near the screen, about 100 here, and reuses them as you scroll. A table of row heights produces the scrollbar size and each row's position, so the list behaves as if every row exists.
2 · Prefix sums of heights
A row's vertical position is the sum of the heights of all rows above it. If every height is known and fixed, you compute these running totals once. If any height changes later, every position below it shifts, and that shift is what makes a page jump.
Netflix previews "ja", Java command-line tools built around the module system
Netflix's JVM team previewed ja, a set of small Java command-line tools. A project's module-info.java holds its dependency versions and permissions. A naming scheme lets the tools fetch most existing Maven Central libraries by module name.
Why it matters · The JDK keeps tightening what code can do at runtime. Tooling that records each permission per module could make that shift easier to live with than writing class-path workarounds.
Know this first
Context from the editor, not the article · 34 sec
1 · Class path vs module path
Java historically loaded code from the class path, a flat list of JARs with no names, boundaries or dependency declarations. The Java Platform Module System (Java 9) added named modules described by module-info.java, which states what a module requires and exports. The JDK checks those rules at compile time and at runtime.
2 · Automatic and unnamed modules
A JAR without a descriptor placed on the module path becomes an "automatic module", named from its manifest's Automatic-Module-Name or, failing that, its filename. Everything on the class path lumps into one "unnamed module", referred to in JVM flags as ALL-UNNAMED.
4/5PlanetScale· Eric Ridge· Sep 16
PlanetScale's TIN gives Postgres full-text search an index keyed on row locations
PlanetScale released TIN, a full-text search index for Postgres. Each indexed document is identified by the row's own physical address. That makes searches cheap bitmap operations and avoids ID translation, and the vendor's benchmarks show it 8× to 50× faster than rival extensions.
Why it matters · One choice of identifier changes compression, query execution, visibility checks and segment merging all at once. That kind of design analysis carries over to other storage engines.
Know this first
Context from the editor, not the article · 34 sec
1 · How text indexes work
A full-text index is an inverted index: for every word it keeps a postings list of the documents containing it. An AND query intersects lists, an OR query unions them. BM25 is the standard formula for ranking matches by term frequency and rarity.
2 · Postgres rows and ctids
Postgres stores table rows in a heap of 8 KB pages. Each row version has a ctid, a (page, slot) address. An update writes a new version with a new ctid. Old versions stay until VACUUM cleans them. Each query sees only versions visible to its snapshot. This scheme is called MVCC.
3 · Existing Postgres options
5/5Cloudflare· Dominik Picheta· Oct 9
Cloudflare adds on-demand CPU and memory profiling for production Workers
Cloudflare now lets developers capture CPU and memory flamegraphs from Workers and Durable Objects running in production. Internal teams used it to make a function 2.7x faster and fix out-of-memory evictions.
Why it matters · It shows how to profile live code spread across thousands of machines without pausing it, and the case studies are the kind of waste any profiler on real traffic tends to turn up.
Know this first
Context from the editor, not the article · 31 sec
1 · Workers and Durable Objects
Cloudflare Workers run JavaScript at the edge, inside V8 isolates: lightweight sandboxes that share one process. Requests go to the nearest data center, so one Worker exists as many copies. Durable Objects are named, stateful Workers, and each name has exactly one live instance somewhere.
2 · Sampling profilers and flamegraphs
A sampling profiler records the call stack at fixed intervals. A flamegraph stacks those samples so that each box is a function and its width is that function's share of samples (CPU time or bytes allocated). Wide boxes are where the cost is.
Searching 'show hn': Lakebase (left) is slow and estimates ~3,400 results; TIN (right) returns the exact count quickly.1 / 4
The news
Developer Rishi Raj Jain built a demo app that searches Hacker News with Postgres's built-in full-text search. It runs on 1, Databricks' hosted serverless Postgres. Patrick Reynolds of PlanetScale forked the app onto 2, PlanetScale's own full-text search for Postgres. This is a vendor comparison against a competitor. The post doesn't explain how TIN works inside and gives no benchmark methodology.
The tilde
For the query show hn, the original app took more than 3.6 seconds and showed "~3,400 results". The true count is exactly 213,447. Reynolds says TIN is tuned for COUNT(*) and returns exact counts on large multi-term queries in a few milliseconds. The post doesn't say how the original estimate was produced.
What fast queries made possible
No debouncing.3 means waiting for a pause in typing before querying. The original waits 250 ms after each keystroke. The TIN version queries on every keystroke.
Autocomplete. An unfinished last word becomes a prefix wildcard, so planetscale datab searches planetscale datab* and finds "database".
Fuzzy fallback. TIN supports 4, the number of single-character edits between two words. If a query returns too few results, the app counts each term separately and allows two edits on the rarest term, then the next rarest. For example, planetscale databse becomes planetscale databse~2, because "databse" appears only ten times on Hacker News. This takes several count queries per search, which is affordable only if counts are cheap.
Stop words indexed. Most setups drop very common words like "to" and "be", so "to be or not to be" returns nothing on Lakebase. TIN indexes everything. The query to b* matched 1,054,718 documents and ranked the top 30 in 11 ms. The post doesn't give the index-size cost.
Price
The Lakebase instance is 32 5 (compute units: 32 cores and 128 GB of RAM in total) with sleep disabled. That costs 32 × 730 h × $0.222 ≈ $5,186 a month. The TIN version is a three-node high-availability cluster of M-160 instances, each with 2 cores, 16 GB of RAM and 118 GB of local 6 storage. It costs $609 a month, under one-eighth as much.
Caveats
Rishi sized the baseline and chose to disable sleep, so the price gap partly reflects his choices. The timings come from single screen recordings, and the post doesn't say whether the Lakebase side could have been tuned. Still, the slowdown of stock Postgres full-text search on large match sets is well known. Reynolds's broader point holds: when counts and fuzzy lookups take milliseconds, the app can run several queries per keystroke.
The news
Developer Rishi Raj Jain recently built a demo app that searches Hacker News posts and comments. It uses Postgres's built-in full-text search and runs on 1, Databricks' hosted serverless Postgres. Patrick Reynolds of PlanetScale forked the app and moved it to 2, PlanetScale's full-text search for Postgres. His post compares the two side by side.
Keep in mind what kind of post this is. A vendor is benchmarking against a competitor, using a demo someone else built and sized, and it ends in a sales pitch. It gives no internals for TIN, no methodology, and no repeated measurements. The interesting part is less the speed claim than the list of things the app could do once queries were cheap.
What caught Reynolds's eye: a tilde
The original demo shows "~3,400 results" for the query show hn, and the query takes more than 3.6 seconds. Reynolds asks two questions. Why can't the database give an exact count? And why does ranking only 3,400 documents take seconds?
The answer is the actual count: exactly 213,447 matches. So Lakebase was ranking a match set about 60 times larger than the number it displayed. The post doesn't say how the original app produced its estimate. A common pattern is to read the query planner's row estimate, because an exact COUNT(*) over a large match set is expensive in stock Postgres. Treat that as a plausible explanation, not something the article states. Reynolds says TIN is "especially well optimized" for COUNT(*) and returns exact counts for large, multi-term queries in a few milliseconds.
Feature 1: no debouncing
3 means waiting until the user pauses typing before sending a query. It protects a slow backend from a request on every keystroke. The original app waits 250 ms after each keystroke. The TIN version queries on every change. Reynolds argues TIN is fast enough that the backend doesn't need that protection. In the side-by-side recording, the TIN app finishes sooner and flickers less.
Feature 2: autocomplete through wildcards
TIN supports prefix wildcards. The app treats an unfinished final word as a prefix, so typing planetscale datab runs the query planetscale datab*. That matches "database". It would also match oddities like "Databall" and "DATAbEEF", but those don't co-occur with "planetscale". Results appear before the user finishes the word, and users who can't spell a word get help.
Feature 3: fuzzy matching with a fallback strategy
TIN supports fuzzy matching by 4, which counts the single-character insertions, deletions and substitutions needed to turn one word into another. The interesting part is how the app decides when to use it:
Run the query exactly as typed.
If it returns too few results, run a separate COUNT(*) for each term on its own.
Allow an edit distance of 2 on the rarest term and run the query again.
If results are still too thin, fuzz the next-rarest term, and so on until every term is fuzzed.
A worked example from the post: planetscale databse. The misspelling "databse" appears only ten times in all of Hacker News, so it is the rarest term. The app rewrites the query as planetscale databse~2, which matches "database". The UI marks which words were fuzzed.
This approach makes several extra count queries for one user search. That only works if counts cost milliseconds, which ties the feature back to the counting claim.
Feature 4: 5 stay in the index
Most setups, Lakebase's included, are configured to drop stop words. A query like "to be or not to be" or "The Who" then reduces to nothing, and the Lakebase app shows no results. TIN indexes every word. While Reynolds typed, the query to b* matched 1,054,718 documents, and TIN ranked the top 30 in 11 ms. The post doesn't report what indexing every word costs in index size or write throughput. That is the usual reason to drop stop words.
Cost
A Neon CU (compute unit) is a bundle of CPU and memory. Here 32 CU means 32 cores and 128 GB of RAM. The Lakebase figure is 32 CU × 730 hours × $0.222, with autosuspend turned off so the instance never sleeps. The TIN price covers a three-node high-availability (HA) cluster with local NVMe (fast solid-state) disks. That is under one-eighth the price, with a sixteenth of the cores and an eighth of the RAM per node.
How much to trust it
The direction of the result is believable. Stock Postgres full-text search is known to slow down when it ranks or counts very large match sets, and the gap here is large: seconds against milliseconds. Some caveats still apply:
The baseline was chosen by someone else. Rishi sized the Neon instance and turned off sleep. A smaller or autoscaling configuration would change the price comparison. The post doesn't say whether the Lakebase side could have been tuned, for example with different ranking queries or indexes.
No methodology. The timings come from single screen recordings, with no percentiles, cold or warm cache details, or concurrency.
Missing costs. The post covers no index size, write or ingest speed, or rebuild time.
TIN is a black box here. The post doesn't explain how TIN gets fast counts or fuzzy matching. An earlier PlanetScale post is cited for raw speed but not summarized.
The engineering point holds regardless of vendor. If counts and fuzzy lookups take milliseconds, the application can afford to run extra queries per user action, and that opens up features a slower backend would rule out.
Remember
Lakebase displayed ~3,400 results for 'show hn'; the exact count was 213,447, which TIN returned in milliseconds.
Cheap COUNT(*) queries let the app fuzz the rarest search term first by running several counts for each search.
Indexing stop words fixes queries like 'to be or not to be', but the post omits the index-size cost.
Reflow is the browser recomputing layout, which gets expensive when reads and writes alternate. ResizeObserver is a browser API that fires a callback when an element changes size. If that callback changes layout, it can trigger itself again.
Image: GitHub Engineering
GitHub's Alberto Gimeno explains how his team rebuilt the pull request view in the GitHub Copilot desktop app. Their test case was an open source pull request with 2,200 files, more than a million changed lines and over 400 inline review comments.
Code is easy, comments aren't
Large diffs rely on 1: only about 100 rows near the screen exist as real 2 elements (the browser's page objects), and they're reused as you scroll. Positions and scrollbar size come from a table of row heights. Every code line has a known height, so that table can be computed up front and never changes. GitHub calls this "all heights known before paint," and kept it for code.
Comments break it. A thread's height depends on markdown wrapping, collapsible <details> sections, a growing reply box, and images that load late. Fixed estimates leave gaps or clip long comments. Writing real heights back into the table shifts everything below while you scroll.
Two geometries
The team split total height into exact code height plus a separate index of "dynamic block" heights. Each block is keyed by identity and anchored to a file, line and side. It also carries a 3 of everything that could change its height, plus a width bucket. Its height is the first that applies: a measurement, a cache hit, or the estimate.
A first design gave each block a ResizeObserver, a browser API that reports size changes, that wrote heights back. They rejected it because it can retrigger itself. Instead, one measurement pass runs only once scrolling stops. It covers blocks within about 2,400px of the screen and reads all mounted blocks in a single batch. Observers just flag blocks for re-reading. The exception is a resize the user caused, like expanding a section, which is applied in the same frame, at most once per frame.
Correcting without jumping
Before applying height changes, the app records what you're looking at and keeps it fixed on screen afterward. In one bug, scrolls the app made itself counted as user scrolls, so a needed correction was skipped. Gimeno's lesson: a "user is interacting" check must not be something your own side effects can trigger.
On the data side, the diff streams the file tree first, syntax highlighting runs off the main thread, and a small cache keeps recent diffs so returning doesn't show an empty pane.
Finding the bugs
Permanent probes check things like mounted counts, one commit per frame and late-inserted blocks. They run as CI budgets against a synthetic huge-PR fixture. An unattended autopilot drives the real app and logs a health signal for every sample.
The post gives no before-and-after timings or memory figures.
GitHub engineer Alberto Gimeno describes how his team rebuilt the pull request view in the GitHub Copilot app, GitHub's desktop client. The goal was to keep reviews fast even when one change can't be split up. Teams often break work into 1, a chain of small dependent changes, but broad refactors and migrations sometimes have to land at once. As a stress test, the team opened the largest pull request they could find: an open source one with 2,200 files, more than a million changed lines and over 400 inline review comments.
Why code is easy and comments are not
Large diffs are a solved problem. The answer is 2: mount only the rows near the viewport (about 100 at a time) and reuse those 3 elements, the browser's in-page objects, as the user scrolls. The scrollbar height is the sum of all row heights. Row N's position is the sum of the heights above it. Deciding what's on screen and jumping to a row are just arithmetic over that table.
General-purpose virtualizers start with estimated heights and correct them as rows get measured. When every row is a line of code at a known font size, though, the whole table can be computed up front and never changes. GitHub calls this the "all heights known before paint" contract. The diff surface is built on it:
a recycled code-row renderer written imperatively, without a React component per row
offset math stored in 4, which are compact fixed-type numeric buffers
diff documents built by the backend and streamed structure-first
an exact "scroll to row N" API
No per-frame work grows with the total row count, and the team kept all of it.
Comments break the contract. A thread's height depends on how its markdown wraps at the current width, collapsible <details> sections, a reply box that grows as you type, suggested-change diffs, reactions, and images that change height when they finish loading. None of that is known until render time, and it keeps changing afterward. Reserving a fixed estimated slot fails at the extremes. Most comments get too much space and leave whitespace gaps. Long ones get too little and get clipped or grow a nested scrollbar. Writing the measured height back into the shared offset table moves everything below it while the user is scrolling, which shows up as a visible jump.
So comments got a weaker promise: heights are bounded and measured lazily, and corrections are small and anchored to whatever the user is looking at.
Two geometries
The key step was to split the height into two independent parts:
Code geometry stays deterministic and is never rebuilt when a comment resizes. Each dynamic block (a thread, draft or reply composer) has three properties:
A stable key. The key reflects what the block is, not where it sits, and survives the content loading.
An anchor. The block is tied to a file, line and side of the diff, not a pixel coordinate, so a 5 can't lose it.
A 6. This records every input that could change the block's height: its content, whether a is open, whether a composer is active. The block also stores the width it was last measured at, rounded into buckets so an ordinary window resize doesn't invalidate everything.
A block's effective height is the first of these that applies: a valid measurement, then a cached height whose fingerprint and width bucket still match, then the estimate. Block heights sit in their own index. Their count grows with comments, not rows, so a few thousand is manageable, provided first paint doesn't measure them all.
The measurement scheduler
The first design gave each block its own ResizeObserver that wrote measured heights back into the layout. The team rejected it during performance hardening. An observer that changes the layout of the element it watches can retrigger itself, and the cost grows with every mounted block.
What shipped is a single measurement pass that waits for idle time and for scrolling to stop:
Off the hot path. The pass runs when the visible range settles, never once per frame, and waits while a scroll is in progress.
Scoped to the viewport. Only blocks within roughly 2,400px of the screen qualify, so the work is proportional to the viewport. Distant blocks keep their estimates.
On-screen reads win. The pass reads all mounted candidates in one batch: one reflow, with no writes in between. It never skips a mounted block in favor of an estimate. Breaking this rule caused the worst bug the team hit: blank strips under comments that had been filtered out of measurement and left on estimates that were too tall.
A bounded off-screen fallback. A nearby block that hasn't mounted gets at most one off-screen render. Blocks taller than the viewport skip even that, because their extra reserved space hides below the fold.
Observers only flag. Each mounted block's ResizeObserver just marks the block for the next pass to re-read. It disconnects on unmount.
There is one deliberate exception. When a user expands a section or opens a reply, waiting for the next pass showed a one-frame mismatch: the comment grew but the code below it hadn't moved yet. For mounted, on-screen blocks, the observer now applies the correction before paint. Two safeguards keep the loop closed: at most one synchronous commit per frame, and none during an active scroll.
Scroll anchoring
Corrections work by identity, not by pixel:
Record the row or block the user is anchored to, plus the offset within it.
Apply the height changes.
Find that anchor's new position.
Scroll so it stays put on screen.
A few rules sit on top. A block above the viewport that changes height gets compensated. Content filling in below the viewport is left alone. A section the user toggles themselves lets the content below flow down naturally. Wheel momentum is never fought.
That last rule caused a subtle bug. The "user is scrolling" guard used the timestamp of the last scroll, and scrolls the app made itself refreshed that timestamp too. Toggling the file-tree sidebar changed the pane's width. With line wrapping on, the whole coordinate space shifted and the surface emitted a small scroll of its own. The guard then skipped the correction, and the user's file drifted off screen. Gimeno's lesson: any "is the user interacting?" check must be one your own side effects can't satisfy.
The data pipeline
Three habits support the UI:
Structure before content. The diff streams so the file tree and metadata appear first, and all review threads are resolved up front.
Deferred per-item work. Syntax highlighting runs off the main thread, so rows show as plain text and get colored later. Large markdown bodies aren't built until they approach the viewport.
A small cache. Diff documents are still released on navigation to save memory. But the metadata persisted, so the header and file tree reappeared instantly around an empty diff, which looked broken. The team now keeps the last few diffs in memory and refreshes stale ones in the background.
Finding the bugs
Bugs like "whitespace below some comments, sometimes, and it heals if you scroll back" were found by tooling rather than by eye. The surface carries permanent structured probes that check its own invariants:
how many rows and blocks are mounted
whether measurement coalesces into one commit per frame, and how long that frame takes
how large the scroll corrections are
whether any block appeared after scrolling began (this must be zero)
whether observers tear down on unmount
These checks run as budgets in an end-to-end CI test against a synthetic huge-PR fixture. A headless lane runs JSON-described flows against a mock server and ranks bottlenecks using render counts, the browser's performance timeline and a requestAnimationFrame jank sampler. An autopilot drives the real desktop app, cold and warm, through toggles, sweeps and resizes. It logs a health signal with each sample: no unfilled gaps, no blank blocks, real thread content mounted. Once a bug is understood, the temporary probes come out and the invariant is pinned in a test.
What the article leaves open
The post reports no before-and-after numbers: no open time, frame times, correction sizes or memory use. The only claim is that the extreme pull request now "behaves like a normally sized pull request." It doesn't describe the height estimator, the width bucket size or how many diffs the cache holds. It also doesn't compare its approach with built-in browser features such as CSS overflow-anchor or content-visibility. These features probably don't fit a recycled, imperative surface, but the post doesn't say so.
Remember
Keep fixed-height and unknown-height content in separate geometries so resizing one never forces a rebuild of the other.
Observers should flag blocks for a batched measurement pass, not write heights themselves, or the layout can retrigger itself.
Guards that detect user activity must ignore side effects the app causes itself, like its own scroll events.
An ongoing OpenJDK effort to make the platform's guarantees hold unless an application explicitly opts out: restricting deep reflection, native code access, and soon mutation of final fields. Opt-outs are granted with launcher flags that name specific modules.
Image: Netflix Tech Blog
Netflix's JVM Ecosystem Team has released a preview of ja, a set of command-line tools for Java built on the 1, the module layer added in Java 9. The idea is that module-info.java, the module descriptor, becomes the complete description of a project.
The problem
Maven and Gradle, Java's main build tools, were mature before modules arrived. So, the team argues, the descriptor became one more file to keep in sync. Dependencies get listed twice, and many projects stay on the 2, the old flat list of JARs with no module boundaries. Java's IDEs are so complete that it also never grew small command-line tools for formatting or symbol search.
How it works
Versions sit in comments next to requires lines (requires com.example.framework; // @1.2.3). Metadata such as the entry point goes in doc tags (@mainClass). ja only orchestrates. The work is done by separate tools you can use on their own:
jig: version resolution, compilation, assembly, and a bridge to Maven repositories
jfmt: a formatter based on Java's classic code conventions
jist: grep-style search over class files and their sources, with no index or language server
jdocserver: local API docs
Each tool implements the JDK's ToolProvider interface, so it can be installed into the JDK and run in-process. ja fills in module-path arguments so standard tools like jlink and jshell work too.
Finding modules on Maven Central
Of the 1,000 most popular Maven Central artifacts, only 232 ship explicit modules, 248 declare just an 3 name, and 520 say nothing. ja maps a module name to an artifact using Maven Central's verified reverse-DNS group IDs, e.g. pkg:maven/com.netflix/com.netflix.tools.ja. It falls back to optional relocation stubs, naming-convention guesses and a short alias list. The team says this finds most existing artifacts from just a name and version.
Integrity by default
The JDK is moving to block actions like changing final fields unless specific modules are authorized. On the class path, those grants usually go to ALL-UNNAMED, which hides which library needs them. In ja, a library declares what it needs (@enableFinalFieldMutation), and the application must repeat that grant explicitly. If it doesn't, resolution fails at build time. Hashes in module-info.hash reject changed artifacts. Annotation processors run as a visible code-generation step, so the generated code goes through code review.
Caveats
This is a preview. The post gives no performance figures, no adoption data, and no details on multi-module builds or how ja compares with Maven or Gradle.
Netflix's JVM Ecosystem Team, writing through Danny Thomas, has released a preview of ja. It is a family of command-line tools for Java built on the 1, the module layer added in Java 9. Most Java tools treat modules as an extra. These make the module descriptor, module-info.java, the single description of a project.
The problem: the descriptor arrived too late
Thomas argues that Maven and Gradle, Java's build tools, had mature models of projects, dependencies and builds well before modules shipped. So the module descriptor became "just another description of the project to keep in agreement". A typical project lists the same dependency twice: once in pom.xml or build.gradle with a version, and once in module-info.java as a requires line without one. Many projects skip modules altogether and stay on the 2, the old flat list of JARs.
The article names a second gap. IDEs do so much for Java developers that Java never grew small command-line tools for formatting, symbol lookup or browsing documentation. Thomas says this shows up when coding agents, which work through a terminal, try to find Java dependencies, docs and sources.
Making the descriptor complete
ja puts versions and metadata into the descriptor itself, using comments and Javadoc-style tags the compiler ignores:
The // @1.2.3 comment pins the dependency version next to its requires line. The @mainClass tag sets the entry point. Commands follow the style of other languages' package managers, for example ja require [email protected].
Small tools that compose
ja itself only handles command-line ergonomics and orchestration. Each feature is a separate tool you can use without ja:
jig resolves module versions, compiles and assembles. It prints standard module-system arguments that other tools can consume. It also connects to Maven repositories, with a standalone module proxy and publishing commands.
jfmt formats code using the old Code Conventions for the Java Programming Language, updated for modern Java. It targets the whitespace, import-ordering and fully qualified name problems the team says are common in machine-written code.
jist is a grep-style search over compiled class files and their sources. It needs no indexing, Language Server Protocol (LSP) server or other protocol server, and it works with other build tools through an argument-file contract.
jdocserver serves API documentation locally.
All of them implement the JDK's Tool or ToolProvider interfaces. These are the standard mechanism for discovering a tool and running it inside an existing JVM process, so the tools can be installed into the JDK next to javac and jar. ja uses OptionChecker to ask each tool which module-system options it accepts, then fills in arguments such as --module-path itself. That means the same project setup works with standard JDK tools: jdeps (dependency analysis), jlink (custom runtime images) and jshell (the REPL).
Finding modules in a repository that doesn't know about them
Maven Central identifies artifacts by groupId:artifactId:version, not by module name. In the team's survey of the 1,000 most popular Maven Central artifacts:
232 ship an explicit module descriptor
248 declare only an 3 name
520 say nothing about module naming
The module system also doesn't separate a namespace from a module name. So a tool that starts from requires com.foo.bar has to work out which artifact to download.
The team's answer builds on the fact that Maven Central already checks namespaces: publishers must prove they control the reverse-DNS group ID. ja defines a canonical coordinate that pairs that verified namespace with the full module name, e.g. pkg:maven/com.netflix/com.netflix.tools.ja. Authors of existing artifacts can publish a single 4 at that coordinate, a stub file that points to the real artifact. If no such stub exists, ja walks candidates from the namespace root using common artifact-naming conventions. It also bundles aliases for popular modules whose names aren't reverse-DNS. An illustrative case, not from the article: for , it might try group with artifact or . jig's proxy serves resolved JARs under filenames that match their module names. Automatic modules get their name from the filename, so even unstable automatic modules resolve the same way every time. The team says this lets most existing artifacts be found from just a module name and version.
Integrity by default
Integrity by Default is the OpenJDK effort to restrict deep reflection, native access and, under the "Preparing to Make Final Mean Final" work, changing final fields at runtime. Each opt-out is a launcher flag naming the modules allowed to break the rule. On the class path that name is usually ALL-UNNAMED. Thomas argues that this hides which library is building up the technical debt.
The library cannot grant itself that access. The application must repeat the tag to authorize it, or add it in one step with ja require [email protected] --enable-final-field-mutation com.example.framework. Without that authorization, dependency resolution fails with an "unsatisfied access requirement". The problem appears at build time, not at runtime. @enableNativeAccess works the same way, and qualified exports and opens are supported too.
Two other integrity measures:
Lockfile-style hashes. Hashes of resolved binary dependencies are stored in module-info.hash. Later resolutions check them and reject any artifact that has changed.
Annotation processing as code generation. Annotation processors run as an explicit step, and their output is saved next to the module's source. Generated code shows up in code review, and a module can be built without running generator code. The team describes this as going further than recent JDK hardening of annotation processors.
What's left open
This is a preview announcement, and the post includes no benchmarks, adoption figures or report on Netflix's internal use. It doesn't say how ja handles multi-module builds, test scopes, version conflicts between transitive dependencies, or how it compares with Maven or Gradle in practice. The whole scheme depends on libraries shipping clean modules. The team's closing advice to library authors is to stop producing automatic modules and 5 (one package spread across several JARs, which JPMS forbids) and to publish explicit modules.
Remember
Only 232 of the 1,000 most popular Maven Central artifacts ship explicit module descriptors, and 520 declare no module name.
Libraries declare the runtime permissions they need, and applications must grant them explicitly or resolution fails at build time.
Each ja feature is a standalone ToolProvider tool, usable without adopting ja itself.
Postgres ships GIN, a general inverted index often used with its built-in tsvector search. ParadeDB (pg_search) and pg_textsearch are third-party extensions that add BM25 ranking. Each gives up something: query types, write concurrency, or memory use.
postings list
For one word, the list of all documents containing it; the core structure of a search index.
Image: PlanetScale
PlanetScale has released TIN ("Text INdex"), a full-text search extension for Postgres. You create it with CREATE INDEX ... USING tin(col) and query it with WHERE col ==> 'words'. It supports Boolean, phrase, fuzzy and regex queries, exact counts, and 1 ranking (the standard relevance formula). The post's central claim is that one design choice makes it much faster than the alternatives.
Benchmarks
PlanetScale tested an 85 GB Stack Exchange corpus of 150 million documents. Postgres was capped at 8 vCPUs and 32 GB RAM, so indexes did not fit in memory, and the 3,762 queries were synthetic. Against 2, the only rival that finished every test:
Mixed top-10 ranked queries: 199 vs 7.9 queries/s, with 99th-percentile latency of 256 ms vs 6.8 s.
With a concurrent writer: TIN went from 148 to 125 queries/s; ParadeDB fell from 17 to 2.2. pg_textsearch, another extension, let readers starve writers and completed just 735 updates in ten minutes, against TIN's 270,279.
Counting matches on in-memory Wikipedia: 10,260 vs 291 queries/s. Postgres's built-in 3 index managed 1.4.
TIN also built its index in 8 minutes within 32 GB. Rivals needed 64–128 GB of RAM, and GIN took over two hours. These are vendor numbers.
The mechanism: postings are 4
Search engines usually number documents 1..n within each index segment because dense numbers compress well. But Postgres identifies rows only by ctid, a 48-bit (page, slot) address. ParadeDB and pg_textsearch therefore keep a map from their own IDs back to ctids, and look up every match in it.
TIN stores ctids directly as two-level bitmaps: one 256-bit bitmap of pages, plus a tiny bitmap of slots per page. This works because an 8 KB page holds at most 291 rows. Common words cost about 1 bit per posting and rare ones about 25.
A 256-bit bitmap fits in one 5 vector register, so AND and OR become single CPU instructions and counts use POPCNT, which counts set bits. Take the AND rareword: ANDing the page bitmaps rules out almost every page before any slot bitmaps are decoded. The position of each set bit is the row address, and rows come back in disk order.
Correctness and maintenance
Postgres's 6 is also a page bitmap. It records which pages hold only rows visible to every transaction. TIN intersects it with its own page bitmaps, so it checks the table itself only for matches on pages that recently changed. Deleted rows are masked out by a liveness bitmap that VACUUM updates.
Segment merges need no renumbering, because a ctid means the same thing in every segment. Whole bitmaps move to the merged segment without being copied.
The post leaves some things open: behaviour on non-x86 CPUs, what happens when VACUUM frees a ctid and a new row reuses it, and performance under sustained heavy writes.
PlanetScale has released TIN ("Text INdex"), a full-text search extension for Postgres. It is generally available on the company's Postgres and Neki databases; the post doesn't describe Neki. You create it like any index (CREATE INDEX ... USING tin(col)) and query it with a new operator, WHERE col ==> 'some words'. Ranked queries add ORDER BY tin.score(ctid) DESC LIMIT 10.
The post comes from Eric Ridge and is a vendor announcement, so treat the benchmarks as vendor numbers. What makes it worth reading is the design argument: one choice of document identifier makes nearly every part of the index cheaper.
The bar PlanetScale set
The team says a good Postgres text index must support all of the following:
Boolean, phrase and span queries
Fuzzy, wildcard and regex term matching
Case and accent folding
Exact COUNT(*) and BM25-ranked top-k results
It must also behave like a real Postgres index. That means it handles joins, mixed WHERE clauses, continuous updates, replication, backups and correct transaction visibility. New rows must be searchable as soon as they commit. The post says none of the three existing options (built-in 1, 2 and pg_textsearch) meets all of these.
Benchmarks
The published results use an 85 GB Stack Exchange dump with 150 million documents. There is no standard query log for that corpus, so the team sampled substrings of 2–15 terms. Each was run as a conjunction (all words), a disjunction (any word) and a phrase, for 3,762 queries in total.
Postgres 18.6 ran in a container capped at 8 vCPUs and 32 GB RAM on an AWS i7i.8xlarge with local NVMe. The cap was deliberately small, so the indexes would not fit entirely in memory. The load generator was a fork of ParadeDB's own benchmarker, run on the same machine.
Builds. TIN built in 8m10s within 32 GB, producing a 50.7 GB index. ParadeDB needed 64 GB of RAM and 19m20s (52.1 GB index). pg_textsearch needed 128 GB and 26m49s (41.5 GB). GIN took 2h09m with 64 GB and produced the smallest index, 28.0 GB.
Queries (QPS = queries per second; p99 = 99th-percentile latency):
Mixed queries, top-10 by 3: TIN ran 199 QPS at 256 ms p99, against ParadeDB's 7.9 QPS at 6.8 s. TIN read 65 MB per query; ParadeDB read 582 MB.
Conjunction and phrase, top-10: TIN 242 QPS, ParadeDB 24, GIN 0.4 (p99 288 s). GIN ranks with ts_rank_cd, its built-in ranking function, not BM25.
GIN ran out of memory on the disjunction workloads. pg_textsearch supports only ranked disjunction queries, so it could not run phrase, conjunction or count workloads. Only TIN and ParadeDB finished every test.
Why it's fast: 4 as document IDs
Most search engines, Lucene among them, split the index into segments and number documents 1..n inside each one. Dense sequential IDs compress well with delta encoding (storing gaps between IDs) and bit-packing. But Postgres speaks only in ctids. An index extension has to return them, and bitmap scans and every built-in index use them. So engines with their own IDs keep a separate map back to ctids. Per the post, ParadeDB and pg_textsearch both do this, so a search matching 10 million rows means 10 million lookups.
TIN uses the 48-bit ctid itself as the posting: 32 bits of page number and 16 bits of slot offset. That avoids the mapping step, but sparse 48-bit numbers compress badly with standard techniques. TIN instead uses two-level bitmaps:
An 8 KB page holds at most 291 tuples, and often 32 or fewer when rows carry text. So per-page offset bitmaps are small and dense.
Page numbers are dense enough for a bitmap of their own. Page-level bitmaps are 256 bits wide, the size of one 5 register (AVX2 and AVX-512 are x86 instruction sets that operate on 256- or 512-bit values at once). Each offset bitmap fits in one AVX-512 register or two AVX2 registers.
Across a corpus, common terms come out near 1 bit per posting, medium-frequency terms around 7, and rare terms near 25, against a raw 48. Terms that appear only once aren't stored as bitmaps at all.
Worked example. Take the AND rareword. "the" appears on almost every page; suppose "rareword" appears only on pages 190 and 5,000 (an illustrative case). TIN ANDs the two page bitmaps 256 pages at a time. Only pages 190 and 5,000 survive, so only their offset bitmaps get decoded. For a count of the OR rareword, if the page bitmaps share no bits, the answer is just the sum of the two exact posting counts stored in metadata. No postings are read.
AND and OR become single vector instructions, and counts use the hardware POPCNT (count set bits) instruction. To return rows, the position of each set bit is the ctid. Results also come out in heap order, so Postgres reads the matching rows mostly sequentially.
Staying correct under 6
When a query returns columns, Postgres must fetch each matching row anyway and reports whether it is visible to the query's snapshot. TIN skips the invisible ones.
Count-only queries can work like an index-only scan. Postgres keeps a 7, a per-page bit meaning "all rows here are visible to everyone." That is the same shape as TIN's page bitmaps, so TIN intersects the two with vector instructions and checks the heap only for matches on pages not marked all-visible. On a fully vacuumed table, a count touches no heap pages. TIN does this inside custom scans rather than leaving per-row checks to the executor.
Deletes go into a per-segment liveness bitmap. When VACUUM removes dead tuples, TIN clears their bits and flags the affected page groups. Queries touching those groups AND their offset bitmaps against the liveness bitmap.
Merging without renumbering
New data lands in mutable segments, which are slower to search. A background worker later converts them into immutable segments and merges small ones into larger ones. In engines with per-segment IDs, merging means renumbering, then recompressing and rewriting everything; the post says that can approach double the storage temporarily. TIN's ctid postings mean the same thing in every segment, so many bitmaps simply change ownership on disk with no copy. That cuts write amplification, meaning extra bytes written per byte of data.
What to weigh
These are vendor benchmarks: a synthetic query set, one machine, and competitors at near-default settings, though the setup is spelled out so others can reproduce it. The speed relies on AVX2 or newer x86 CPUs, and the post says nothing about ARM. TIN's index is about 80% larger than GIN's.
Some questions go unanswered. Can a ctid freed by VACUUM and then reused by a new row ever collide with stale postings? How do HOT updates interact with the index? HOT updates are Postgres's in-page updates that skip index changes when no indexed column changed. How quickly do mutable segments get promoted under sustained writes? The post explains the mechanisms but publishes no data on these.
Remember
Using the database's native row address as the document ID removes ID-mapping lookups and the renumbering normally needed when segments merge.
Two-level bitmaps work because an 8 KB Postgres page holds at most 291 rows, so slot bitmaps stay tiny.
Rival extensions failed in revealing ways: GIN ran out of memory on OR queries, and pg_textsearch let readers block writers.
Local runs don't see production's request mix, volume or data shapes. Before this feature, Workers supported profiling only locally, through Chrome DevTools. Some costs only show up under real traffic.
CPU flamegraph of the R2 binding Worker from a 50-second production profile.1 / 4
Cloudflare now lets developers profile Workers, its JavaScript functions that run at the edge, and 1, its named, stateful Workers, while they serve production traffic. From the dashboard's Observability tab or the cf command-line tool, you pick CPU or memory, a duration and a deployed version. You get back an interactive 2 and a downloadable profile file. In a flamegraph, each box is a function, and its width is that function's share of CPU time or memory.
Why it matters
Before this, Workers could be profiled only locally through Chrome DevTools, which doesn't reproduce production's traffic. Metrics can show that a Worker is short on CPU or memory, but not which function is to blame.
What it found internally
3 binding Worker. This Worker handles calls to R2, Cloudflare's object store. A 50-second CPU profile, sorted by sample count, surfaced a 4 taking over 5% of CPU. JSON.stringify already calls a replacer on every node of the object tree, but this one also walked the tree itself, so a value five levels deep was processed five times. Fixing it made the function 2.7x faster. The same profile showed a metrics call made twice, each costing about 1% of CPU, so the second call was dropped.
A Worker near its memory limit. Its 5 memory (the level 99.9% of samples stay under) sat at about 133 MB against a 128 MB limit, which got it evicted often. A heap profile opened in 6, a standard profile viewer, showed that 7 metrics code thought to be disabled accounted for about 66.7% of allocations. After the team removed it, P50 fell from 70 to 54 MB and P999 from 133 to 118 MB.
How it works
Workers are copied across data centers and physical servers, so a profile request has to find a loaded 8 (V8's lightweight sandbox) for the right version and account. A Durable Object has to be found at its single live, possibly moving, location. For stateless Workers, the system picks a running isolate at random. For Durable Objects, you name the one you want. No new isolate is started, so a Worker with little traffic may be hard to profile.
To keep the Worker running, the runtime takes the 9 only to start V8's profiler, which samples every 1 ms, and again to stop it. The result is serialized after the lock is released.
Limits
Someone has to start each profile, so short incidents can be missed. The memory profiler also doesn't see allocations made at startup. Cloudflare says it is working on continuous profiling.
Cloudflare has added on-demand CPU and memory profiling for Workers, its JavaScript functions that run at the edge, and for 1, its named, stateful variant of Workers. From a Worker's Observability tab in the dashboard, or from the cf command-line tool, a developer picks a profile type, a duration and a deployed version. The system then records the live code and returns an interactive 2 plus a downloadable profile file. The post, by Dominik Picheta, covers both how to use it and how it works underneath, with three internal cases where it found real waste.
The problem: logs say that, not where
Aggregate metrics can tell you a Worker is burning CPU or running out of memory. They can't tell you which function is responsible. Until now, Workers could be profiled only locally through Chrome DevTools, and a local run doesn't see production's traffic volume or request mix. Cloudflare's position is blunt: the best way to understand an application is to profile it in production.
In a flamegraph, each rectangle is a function call, and its width is the CPU time or memory that function accounts for. You can click to zoom in and hover for details. There is also a table view sortable by sample count, which helps when the costly functions are narrow boxes scattered across many stacks. TypeScript projects need 3 (files that map compiled code back to the original source) enabled, or the function names come out mangled.
Case 1: a 4 doing quadratic work
The Worker that implements the 5 binding gets heavy traffic. R2 is Cloudflare's object store, and a "binding" is how a Worker calls it. On a Worker this busy, small savings add up. A 50-second CPU profile showed the expected wide boxes: decryptBlock, which decrypts object data on the way out, and fillResponse, which copies bytes. Neither offered an obvious fix.
Sorting the table view by samples surfaced genericR2JsonReplacer. It took over 5% of CPU and called itself recursively. A JSON replacer is a callback passed to JSON.stringify, and JSON.stringify already calls it on every node of the object tree. This replacer also walked the tree itself, so the work was duplicated. A value five levels deep was processed five times. As an illustration: for a node at depth d, the outer traversal reaches it once and every ancestor's own walk reaches it again, so the cost grows with depth rather than staying constant per node. Removing the extra walk made the function 2.7x faster.
The same profile showed a metrics call made twice in the same code path. One call alone was about 1% of CPU, so storing the first result in a variable saved a noticeable amount. The post doesn't give a combined end-to-end CPU figure for the Worker.
Case 2: telemetry that was "disabled"
Another internal Worker kept hitting its 128 MB memory limit, with 6 memory (the level 99.9% of samples stay under) around 133 MB. The runtime evicted it often with "Exceeded Memory" errors. Metrics pointed at memory but not at the code responsible.
The team took a heap profile in production and opened it in 7, Google's standard profile viewer. 8 instrumentation (metrics-collection code) accounted for roughly 66.7% of allocations. That code was supposed to be off. It was only partly disabled: it still instrumented many code paths and still paid the memory cost, even though the data it collected never left the Worker. Nobody suspected it, because everyone believed it was off. The team removed the code path entirely:
Percentile
Before (MB)
After (MB)
P50
70
54
P90
94
79
P99
113
97
P999
133
118
The P999 now sits about 10 MB under the limit, and the "Exceeded Memory" error graph dropped to match.
How a profile request finds its target
Profiling a distributed runtime starts with a routing problem. A Worker is replicated across data centers and across "metals" (Cloudflare's word for physical servers). Under load, one metal may run several copies. Before profiling can begin, the system has to answer several questions. Which version is wanted? In which data center has it run recently? Is its 9 still loaded there? Does the isolate belong only to the requesting account? For a Durable Object, which can be placed dynamically and can move, where is the live primary instance right now?
The client answers some of these. The request URL names both the script and the version. The system deliberately does not start a fresh isolate to profile, because the point is to watch real production execution. The catch is that a Worker with little traffic may be hard to profile at all, and you need to choose a deployed version that is actually serving requests.
Regular Workers are stateless and interchangeable, so the system picks a running isolate at random. Durable Objects have names. You can name the object you want, and the runtime routes the request to the metal that owns that actor and profiles its exact isolate.
Profiling without stopping the program
In V8, Google's JavaScript engine, only one thread can run inside an isolate at a time, enforced by the 10. Holding that lock for the whole profile would freeze the Worker and leave nothing to measure. So the runtime holds the lock only around lifecycle steps:
Acquire the isolate and its lock.
Create a V8 CPU profiler and start sampling at a 1 ms interval.
Release the lock so requests keep running.
Wait for the requested duration.
Reacquire the lock and stop the profiler.
Release the lock, then serialize the result outside the critical section.
Serializing after the lock is released keeps the potentially slow output step from blocking request handling.
Limits and what's next
The post names two limits. First, profiling is on demand: someone has to start it, so a short burst of bad behavior can be missed. Second, the memory profiler only sees allocations made during the profiling window, so memory allocated at startup doesn't appear. Cloudflare says it is building continuous profiling, which would collect samples automatically and make rare events easier to catch. The post gives no figures on profiler overhead, and no date or sampling rate for the continuous version.
Remember
The profiler holds the isolate lock only to start and stop, so production requests keep running while it samples.
Both internal wins were waste: duplicate tree walks, a repeated call, and metrics code believed to be off.
Memory profiles see only allocations made during the profiling window, so startup memory doesn't show up.
Disjunction with a writer aiming for 1,000 updates/s: TIN dropped only from 148 to 125 QPS. ParadeDB fell from 17 to 2.2 QPS. pg_textsearch held 3.5 QPS, but only because readers starved the writer of locks: in ten minutes it completed 735 updates, against TIN's 270,279.
In-memory Wikipedia (8 GB), disjunction COUNT(*): TIN 10,260 QPS at 2 ms p99, ParadeDB 291, GIN 1.4.