# Konstantinos Papadopoulos — complete essays > Building beautiful things. Tech + quant finance. Developer, Chainlink Labs, based in London, UK. Source: https://knspap.com. 5 essays, newest first. Each essay is also served on its own at https://knspap.com/essays//index.md. --- # The Cost of a Clever Abstraction I once replaced four hundred lines of repetitive code with forty lines nobody could read. Here is the arithmetic I now do before doing that again. Author: Konstantinos Papadopoulos (https://knspap.com) Published: 2026-08-22 Reading time: 2 min (436 words) Tags: Practice, Architecture Canonical URL: https://knspap.com/essays/the-cost-of-a-clever-abstraction --- There is a particular high that comes from collapsing repetition. You see the same shape five times, you feel the itch, and an hour later there is one generic function where five specific ones used to be. The diff is beautiful. It removes three hundred and sixty lines. Six months later someone needs a sixth case that is almost the same, and you discover what you actually built. ## What the repetition was doing The five functions were not duplicated code. They were five independent decisions that happened, at that moment, to have the same shape. Duplication and coincidence look identical in a diff, and only one of them is safe to remove. When I merged them, I asserted that the five would always change together. Nobody asked me whether that was true. The compiler certainly did not. And it was not true — three of the five had different owners, different release cadences, and eventually different requirements. ## The arithmetic Before collapsing repeated code I now ask three questions, in this order: 1. **If one of these changed, would the others have to?** If the honest answer is "not necessarily", it is coincidence, not duplication. 2. **How many parameters does the shared version need?** Every parameter is a place where the cases disagree. Past three, the abstraction is mostly a record of disagreement. 3. **Can I explain the shared thing in a sentence with no "or"?** "Formats a currency amount" survives that test. "Formats a currency amount, or a date, or a percentage depending on the type flag" does not. Three failures out of three is a decision. Two out of three is a conversation. Zero is a refactor worth doing. > Duplication is cheap to fix later and obvious when it hurts. The wrong abstraction is expensive to fix later and invisible until it does. ## The version I would write now Leave the five. Extract only the genuinely shared *primitive* — the currency formatter, the date parser, the retry loop — and let the five call it. You get the deduplication where the meaning is actually shared, and each caller keeps the right to diverge without asking permission. This produces more lines. It is the correct number of lines. ## The one exception None of this applies inside a single module with a single owner, where the cost of being wrong is a ten-minute refactor by the person who wrote it. Collapse away. The rule earns its keep at boundaries — between teams, between services, between anything with its own deploy — where the wrong abstraction becomes a treaty rather than a function. --- # Half a Million Points on a Phone Velvynote renders a world map of user notes on hardware that is mostly a battery with a screen. The map was never the hard part — the transitions between zoom levels were. Author: Konstantinos Papadopoulos (https://knspap.com) Published: 2026-07-18 Reading time: 3 min (466 words) Tags: Geospatial, Performance Canonical URL: https://knspap.com/essays/half-a-million-points-on-a-phone --- The pitch for Velvynote is simple enough to fit in a sentence: leave a note somewhere real, and find what strangers left in the same place. The engineering consequence of that sentence is that a phone in a train tunnel has to draw a map of the entire planet and feel like it is not working hard. ## The naive version, and why it dies The first build fetched every note in the viewport and dropped a DOM marker on each one. This works beautifully in a demo, where the dataset is forty notes you made yourself. It falls over the moment a city has a thousand. Markers are the expensive part. Each one is a positioned element the browser has to lay out, composite, and reposition on every frame of a pan. A thousand of them turns a sixty-hertz gesture into a slideshow, and no amount of `will-change` rescues it. ## Move the work into the map MapLibre clusters natively, on the GPU side of the fence, and the fix was to stop treating clustering as something the application does: ```js map.addSource("memories", { type: "geojson", data: featureCollection, cluster: true, clusterMaxZoom: 13, clusterRadius: 60, }); ``` Three numbers, and the frame budget came back. `clusterRadius: 60` is the one worth arguing about — it is measured in screen pixels, not metres, which means the grouping stays visually consistent as you zoom, and two notes in the same building never render as two overlapping dots. ## Don't fetch what nobody can see The second constraint is the network. Below zoom 8, the viewport covers a continent, and a continent's worth of notes is both useless to look at and expensive to move: ```sql -- get_memories_for_viewport(bbox, limit) select id, st_asgeojson(location)::json as geometry, preview from memories where location && st_makeenvelope($1, $2, $3, $4, 4326) order by created_at desc limit 500; ``` The bounding-box operator `&&` hits the GiST index rather than scanning; the `limit 500` is the honest admission that nobody reads five hundred notes at once. Below zoom 8 the client does not call at all. There is nothing to show at that scale that a cluster count would not show better. > The performance work that mattered was not making things faster. It was deciding what never needed to happen. ## Where the feel actually comes from With the frame budget recovered, the remaining work was entirely perceptual. A cluster that vanishes and reappears as individual points reads as a bug. A cluster that expands, with the children easing outward from where the parent stood, reads as the map explaining itself. That transition is maybe forty lines. It is also the thing every single person has mentioned after using it — which is a fair summary of how the effort and the credit distribute in interface work. --- # Reading the Query Plan Postgres will tell you exactly why your query is slow, in a format designed to be ignored. Four things to look at, in order. Author: Konstantinos Papadopoulos (https://knspap.com) Published: 2026-06-09 Reading time: 3 min (484 words) Tags: Postgres, Performance Canonical URL: https://knspap.com/essays/reading-the-query-plan --- Almost every slow query I have fixed was fixed by reading the plan properly, and almost every hour I wasted before that was spent guessing. `EXPLAIN ANALYZE` is not a diagnostic of last resort. It is the first thing to run. The output is genuinely hostile to read — nested, verbose, and printed inside out. But you only ever need four things from it, and they are always in the same places. ## One: use ANALYZE, and BUFFERS ```sql explain (analyze, buffers, format text) select ...; ``` Plain `EXPLAIN` gives you the planner's *guess*. `ANALYZE` actually runs the query and reports what happened. `BUFFERS` tells you how many pages came from cache versus disk, which is the difference between "this query is slow" and "this query is slow the first time". The one caution: `ANALYZE` really executes. Wrap writes in a transaction you roll back. ## Two: find the row-count lie Every node prints two numbers: ``` Seq Scan on memories (cost=0.00..18234.00 rows=412 width=64) (actual time=0.02..92.41 rows=284119 loops=1) ``` Estimated 412 rows. Got 284,119. That gap is the whole bug. The planner chose a sequential scan and a nested loop above it because it believed there were four hundred rows; with a quarter of a million, both choices are catastrophic. Find the *deepest* node where estimate and actual diverge by more than an order of magnitude. Everything above it is a bad decision made for a good reason, and fixing the leaf usually fixes the tree. Bad estimates almost always mean stale or missing statistics: ```sql analyze memories; alter table memories alter column tenant_id set statistics 1000; ``` ## Three: read the times inside out Timings are cumulative — a node's `actual time` includes all its children. To find where the time actually went, subtract. And note `loops`. A node showing `actual time=0.8..1.2 rows=3 loops=94000` did not take one millisecond. It took ninety-four seconds. This is the single most commonly misread number in the entire output, and it is how a nested loop hides. ## Four: know which three node types matter - **Seq Scan** on a large table where you expected an index. Either the index does not exist, or the predicate cannot use it — a function on the column, a type mismatch, a leading wildcard. - **Nested Loop** with a large outer row count. Fine for three rows, ruinous for three hundred thousand. - **Sort** or **Hash** with `Disk:` in the buffers line. It spilled. Sometimes the fix is genuinely one `work_mem` setting. Everything else is usually a consequence of one of those three. ## The habit worth building Run `EXPLAIN ANALYZE` on queries that are *fast*, occasionally, while nothing is wrong. You learn what a healthy plan looks like for your schema, and the day one goes bad you will recognise the shape of it immediately instead of reading four hundred lines of tree for the first time under pressure. --- # Migrations That Survive Production Every project I have shipped eventually developed two sources of truth for its schema. Here is the discipline that finally stopped it happening. Author: Konstantinos Papadopoulos (https://knspap.com) Published: 2026-05-02 Reading time: 2 min (414 words) Tags: Postgres, Practice Canonical URL: https://knspap.com/essays/migrations-that-survive-production --- There is a specific failure that shows up in every project past its first year. The schema exists in three places: the migrations directory, a `schema.sql` someone dumped once, and the actual production database, which quietly disagrees with both. Nobody decides to do this. It accumulates. ## One directory owns the schema The rule I now apply from the first commit: exactly one directory contains migrations, and it lives in the service that owns the database. Not the frontend repo. Not a shared `infra` repo. The service. When Velvynote's backend was split out, the first thing that moved was the schema, and the first thing that got deleted was the old `supabase/migrations` folder that had been shadowing it. Two ordered sequences of migrations against one database is not redundancy — it is a race. ## Baselines are allowed The objection to consolidating is always the same: we would lose the history. You would, and it does not matter. A migration's job is to get a database from the previous state to the next one. Once the previous state is "does not exist", the entire history collapses into one file: ``` migrations/ 0001_baseline.ts # applies db/0001_baseline.sql 0002_security-fixes.ts 0003_admin-ensure-capacity.ts db/0001_baseline.sql # the full starting schema ``` `0001` is not a lie about history. It is a statement that history before this point is not executable any more, which was already true. ## Keep a snapshot, and never apply it The one artefact worth keeping alongside the migrations is a dump of the live schema, committed and regenerated on every deploy. It is never applied to anything. It exists so that a diff against it tells you, in one command, whether production has drifted: ```bash pg_dump --schema-only --no-owner "$DATABASE_URL" > schema.sql git diff --exit-code schema.sql ``` If that command fails in CI, someone changed production by hand. That is worth failing a build over. ## Down migrations are mostly a fiction Every framework offers `migrate:down`, and it is genuinely useful in development, where you are iterating on `0007` and want to try again. In production it is close to useless: the down migration for "dropped a column" is "recreate the column, without the data", which is not a rollback, it is a differently broken state. Plan forward instead. Add the new column, backfill it, switch the reads, and drop the old one in a later deploy. Four boring migrations that are each individually safe to run beat one clever migration you would need to reverse under pressure. --- # Motion With Intent Most animation on the web is decoration wearing a costume. The test I use to decide whether a transition earns its milliseconds. Author: Konstantinos Papadopoulos (https://knspap.com) Published: 2026-02-11 Reading time: 2 min (437 words) Tags: Interface, Motion Canonical URL: https://knspap.com/essays/motion-with-intent --- I like animated interfaces and I am increasingly suspicious of them. Somewhere between the CSS transition and the scroll-linked timeline, motion stopped being a way of explaining state and became a way of signalling effort. The test I apply is a single question: **if I removed this transition, would the user lose information?** ## Transitions that pass A modal scaling up from the button that opened it tells you where you are and how to get back. A list item sliding into the position a deleted row vacated tells you the deletion was real and which row it was. A cluster on a map expanding outward tells you those points were always there, grouped. In each case the animation is carrying an idea — *this came from there*, *this became that* — that the static frames on either side cannot express. ## Transitions that fail Everything that fades in because it is below the fold. I say that as someone who used a scroll reveal on the page you are reading. It is defensible only at the scale of a section: it paces the reading, and it hides the fact that a long page's content is not all equally important. Applied to every card in a grid, staggered by eighty milliseconds, it stops being pacing and becomes a queue you are made to wait in. ## The two rules I keep **Duration follows distance.** A dropdown travelling twelve pixels wants 150ms. A full-page transition travelling the height of the viewport wants 700ms. Using one duration for both makes the small thing sluggish and the large thing violent. **Easing follows agency.** Anything the user initiated should start immediately and decelerate into place — `cubic-bezier(0.16, 1, 0.3, 1)` is the curve I reach for, because it moves most of the distance in the first third and the interface feels like it was already waiting for the click. Anything the system initiated, with no click behind it, can afford to ease in as well as out; it has not made a promise to anyone. ## And then turn it all off ```css @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } ``` This blunt instrument only works if every animation's *rest* state is the correct one — if elements animate from `opacity: 0`, that reset leaves a blank page. The discipline is to author reveals so that the finished state is what renders by default, and the motion is what gets added for people who want it. Which is, conveniently, also the right way to build them for anyone whose JavaScript never arrives.