Full Width [alt+shift+f] Shortcuts [alt+shift+k]
Sign Up [alt+shift+s] Log In [alt+shift+l]
1

Lexxy: A new rich text editor for Rails

from 37signals Dev [alt+shift+b] in programming

Today, we are introducing Lexxy, a new rich text editor for Action Text. It’s based on Lexical — Meta’s text editing framework — and it brings a much better text editing experience to Rails: Good HTML semantics. Paragraphs are real <p> tags, as they should be. Markdown support: shortcuts, auto-formatting on paste. Real-time code syntax highlighting. Create links by pasting URLs on selected text. Configurable prompts. Support for mentions and other interactive prompts with multiple loading and filtering strategies. Preview attachments like PDFs and Videos in the editor. Works seamlessly with Action Text and Active Storage. We created Lexxy because Trix was falling short of expectations in certain areas, and we encountered technical barriers when attempting to offer the experience we wanted. Lexxy comes with a bunch of juicy improvements, but more than that, we now have a fantastic foundation to build on top of. Lexxy will also bring a great improvement to Action Text: we will let you configure the editor in Action Text just like you configure the database in Active Record. This will open the door to integrating other editors in Rails. Text editing is central to our products. We believe Lexxy will let us deliver the editing experience we want. We are launching an early beta today, give it a try!
4th Sep 2025

Stay updated

Get a weekly newsletter with the top 5 articles worth reading every week.

More from 37signals Dev

Warming up the Puma master before it forks

Basecamp 5 runs on Puma in cluster mode: one master process with preload_app! and 63 single-threaded workers per host, deployed as a Docker container with Kamal. We serve Basecamp from several sites. Each site has its own web hosts and a read replica of the database, and writes go to a single primary database in one of them. On our busiest hosts, each deploy left up to 2,000 requests waiting while the new workers warmed up. We reduced those queues by running signed-in requests through the app in the Puma master, before it forked the workers. Why 63 single-threaded workers? Basecamp has always served web requests from processes rather than threads. It ran on Unicorn, which only does processes, until we moved to Puma in January 2025, and we kept the same setup: workers (Concurrent.physical_processor_count * 1.3).ceil threads 1, 1 preload_app! On a 48-core host that’s 63 workers, each handling one request at a time. We chose 1.3 after benchmarking HEY in 2023, when we moved our apps out of the cloud and onto our own hardware. We tested several combinations of workers and threads with a mix of GET and POST requests on a 32-vCPU VM. Every multithreaded configuration we tested was slower and handled fewer requests than single-threaded workers. Adding workers beyond about 1.2 to 1.3 per vCPU brought little benefit. The threaded workers spent a lot of their time waiting for Ruby’s global VM lock. That made single-threaded workers a good fit for this workload, and we use the same setup for Basecamp. An app that spends more time waiting on its database or other services may benefit from more threads, so benchmark your own app. The other reason is the app itself. Basecamp has class-level state in places and has never needed to be thread-safe. With one request per process, it still doesn’t. Processes do use more memory than threads, and preload_app! reduces the difference. The master loads the app once and the workers share its memory through copy-on-write until they write to it. Shopify’s comparison of Ruby execution models explains the trade-off well. In the HEY benchmark the best setup came to about 260 MB of PSS per core, where PSS counts each shared page once, split between the processes using it, and the gap to a threaded setup was smaller than we’d expected. What Puma does on each host when a container starts: one master, then 63 forked workers that share its memory until they write to it. Two things about this setup matter for the rest of the post. A worker that’s compiling or loading something is fully blocked — there’s no other thread to pick up the next request. And whatever the master has in memory before it forks, all 63 workers share. Whatever they build after the fork, they build 63 times. What happens when we deploy Kamal starts the new container alongside the old one, and kamal-proxy moves the host’s traffic across as soon as the health check passes. At that moment, the new workers have handled health checks but no customer requests. preload_app! means the master loads the app once and the workers inherit it through fork. That covers the code. It doesn’t cover anything Ruby and Rails set up on first use: YJIT compiled code. YJIT compiles a method once it’s been called a certain number of times. The master calls very little during boot, so every worker compiles the same methods again on its own first requests. Compiled templates. Action View turns each ERB template into a Ruby method the first time it’s rendered. The schema cache. Active Record reads each model’s columns from the database the first time that model is used. Inline caches and memoized values throughout Ruby, Rails and the app. All 63 workers did all of this at once, while serving the traffic the old container had been handling a second earlier. In the test environment with YJIT on, the first request to a project page on a cold process took 652 ms, 151 ms of it YJIT compiling. The same request to a warm process took 28 ms. In production, CPU time per request peaked at around 200 ms while kamal-proxy moved traffic to the new container, against about 30 ms once the workers had warmed up. A host with spare CPU absorbs this. Every one of our web hosts has 48 cores and 63 workers, but each Amsterdam host serves around 250 requests per second, against 25 to 60 at our other sites. In Amsterdam the slow first requests turned into a queue. At a peak-hour deploy, the Puma backlog on an Amsterdam host reached anywhere from 250 to 2,238 requests, and kamal-proxy’s p99 response time hit about 10 seconds. Eron, our Director of Operations, had been tracking this since June. Another server in Amsterdam would help, but it would take weeks to arrive, so we also wanted to make deploys cheaper on the hardware we already had. What didn’t work We tried a few things first. In June, Donal tested the first two on a single Amsterdam host, comparing it with its neighbors, and they ruled out two likely causes. Warming each worker’s database connections. Puma’s before_fork hook clears the master’s connections, and each worker opened its own on its first request. Opening them in before_worker_boot instead made no difference. Queries on a freshly booted production host were already under a millisecond, so connections weren’t the problem. A synthetic request in each worker. Next, each worker made a few requests in before_worker_boot to an internal controller that touched every model. That ran the middleware, routing and Active Record paths, but it ran them in 63 workers at once — exactly the CPU spike we were trying to avoid. And a request with no real data renders no real views, so most of the app stayed cold. Spreading YJIT compilation out. Delaying YJIT in each worker by a random interval spread the compiling out over a few minutes, but every worker still ran interpreted until its delay ended. The queue didn’t change. Reforking from a warm worker. This is what Shopify’s Pitchfork does: let one worker serve traffic until it’s warm, then fork the others from it. Puma has an experimental version called fork_worker, and on beta it worked — the reforked workers were warm after three to five requests, where fresh ones took up to 30 seconds. But with fork_worker the template is worker 0, and it keeps serving requests. If it exits, the workers waiting to be forked never start (puma/puma#3596). If it gets no traffic, the refork never happens, which is what we saw on beta. Instacart have a mold_worker patch that promotes a warm worker to a template that stops serving, but it isn’t in a Puma release. We have a branch of it, and we may come back to it. That last experiment did show us where the fix was, though. Everything a warm worker has that a cold one lacks is in its memory, and fork copies memory. The master already has the app loaded. It just never runs it. Run the requests in the master So now, before the master binds its socket and forks, it makes the app’s own requests, in-process, the way a signed-in user would. Rack has a hook for exactly this. Rack::Builder#warmup takes a block that’s called once with the built app, before the server starts. rails server builds the app from config.ru, so the change to boot is one line: require_relative "config/environment" warmup { WarmUp.configured.run } if ENV["WARM_UP"] run Rails.application With preload_app! this runs in the master, and the workers inherit whatever it did. Puma binds its socket after the app is built, so until the warm-up finishes the health check’s connection is refused and kamal-proxy keeps retrying. No request reaches a worker that hasn’t been warmed. The warm-up has three steps. After precompiling the views, it gives the page requests and schema loading a shared 20-second budget, checked before each page or model. 1. Precompile the views actionview_precompiler reads every template for its render calls and compiles each one with the locals it’s passed. For us that’s 1,394 templates in about two seconds. A first request to a project page then compiles 2 templates instead of 44. 2. Request the pages, signed in A small browser class makes the requests through Rack::MockRequest, with the two cookies a real sign-in sets, then goes back for each page’s lazy Turbo frames: class WarmUp::Browser def initialize(signed_in_as:) @client = Rack::MockRequest.new(Rails.application) @headers = { "HTTP_USER_AGENT" => "Basecamp warm-up", "HTTP_COOKIE" => cookie_for(signed_in_as), "bc3.warm_up" => true } end def visit(path) page = get(path) frames_in(page).each { |id, src| get(src, "HTTP_TURBO_FRAME" => id) } end private def get(path, headers = {}) @client.get("https://#{host}#{path}", @headers.merge(headers)) end def frames_in(page) Nokogiri::HTML5(page.body).css("turbo-frame[src]").map { |frame| [ frame["id"], frame["src"] ] } end end The requests are signed in. The user is a monitoring account we already use for automated checks, and the pages are its own project, Campfire, to-dos, documents and messages. Public pages weren’t enough: after warming up with signed-out pages only, the first signed-in request to the projects page still took 131 ms, because authentication, the signed-in controllers and their views had never run. With signed-in pages it took 40 ms. cookie_for writes the same signed cookie the sign-in controller does, using the app’s own cookie jar, so there’s no API token and no secret to store. The frames are followed. The busiest HTML requests in production aren’t pages at all but Turbo frames — the sidebar badge, the inbox, the navigation menus. The browser parses each page and requests its <turbo-frame src> URLs with the Turbo-Frame header, so those controllers and views get warmed too. Our first four pages turned into 60 requests. The requests are excluded from rate limiting. They are internal, so they do not count against the rate limits that apply to real visitors. 3. Load the rest of the schema The page requests load the schema for the models they touch. The last step loads the rest, from the read replica: ApplicationRecord.reading do models.lazy.take_while { time_left? }.each { |model| model.load_schema if model.table_exists? } end The step checks 261 models and loads any schema information still missing. Those database round trips add up when the primary is far away: outside a request, Active Record uses the writing role, and from a host a long way from the primary each round trip is tens of milliseconds. Reading from the local replica brings the step down from about 20 seconds to 3.5. The pages go first because they load most of the schema anyway. If the time budget runs out, the step stops, logs how many models it got through, and the workers load the rest on first use like they always did. Rails can also load the schema from a dumped cache file at boot (bin/rails db:schema:cache:dump), which would make this step unnecessary. We don’t ship one in our image yet, because the dump needs a database to read from at build time, and we have several databases to cover. It’s on the list. What to close before the fork Running requests in the master opens things the master never opened before, and every worker inherits them. Two processes writing to the same socket will corrupt each other’s traffic, so you need to know what’s open before you fork. The way to find out is to list the master’s open file descriptors — ls -l /proc/<pid>/fd — before and after a warm-up, in an environment set up like production. Development wasn’t enough for us: it stores files on disk, so our S3 connections only showed up in production. Then, for each thing that’s open, check how its library handles a fork. We found three kinds: Already handled. Plenty of libraries detect a fork on their own, either by recording the PID they connected from and reconnecting in the child, by opening per-process files, or by resetting their thread pools. Redis clients, metrics libraries and concurrency libraries tend to be in this group. Check, but you probably don’t need to do anything. Already closed. Database connections are the classic one, and most Puma configs already clear them in before_fork. Anything else that’s opened per process — we have a SQLite cache the workers open on boot — needs closing when the warm-up finishes. Needs a new step. HTTP clients with keep-alive connections are the ones to look for: cloud SDKs with connection pools, tracing exporters, error reporters. They usually have no fork handling at all. We empty the aws-sdk connection pools in before_fork, and we run the warm-up untraced so the OpenTelemetry exporter never opens its connection to Tempo in the first place. Once that’s done, before_fork finishes with Process.warmup, which Ruby 3.3 added for this purpose: a major GC, a heap compaction, and every surviving object promoted to the old generation, so the memory pages the workers share change as little as possible afterwards. Choosing the pages The first list was the four pages that ran the busiest requests on beta. Once the warm-up was live, production showed us which endpoints were still cold. For one deploy, we compared each endpoint’s mean duration in the six minutes after kamal-proxy moved traffic to the new container with the same endpoint an hour later, then multiplied the difference by the number of requests in those six minutes. That gives the extra time each endpoint cost us because it was cold: Endpoint Cold Warm Requests in 6 min Extra seconds Campfire 246 ms 70 ms 6,490 1,140 Projects (JSON API) 84 ms 50 ms 22,077 771 Docs & Files 262 ms 177 ms 4,996 421 To-dos tool 205 ms 113 ms 4,018 371 To-dos (JSON API) 33 ms 16 ms 18,738 320 The pages already in the warm-up showed what to expect: the project page kept a 36 ms gap after a deploy, and the to-do page 10 ms. We’ve proposed adding these five requests, and expect them to add about five to seven seconds to the page step. The two JSON endpoints were a surprise. The warm-up’s page list had no API requests in it, so nothing on the API path had run before the first real request: not the API controllers, and not the Jbuilder templates rendering real records. Precompiling the views covers JSON templates too, but it isn’t a substitute for running the request. Results The warm-up is on for all 68 web hosts. With the first four pages it took 12 to 16 seconds per host: about 2 seconds to precompile the views, 7 to 9 for the 60 requests, and 3.5 for the schema. Deploys take that much longer per host, and we raised the deploy timeout from 30 to 60 seconds to cover it. In Amsterdam, at a peak-hour deploy: During deploy Before After Peak Puma backlog per host 250–2,238 requests 19–223 requests Peak kamal-proxy p99 about 10 s 2.4–4.8 s Peak CPU time per request 201–214 ms 88–132 ms Peak database time per request 56–69 ms 39–47 ms The same eight hosts at three deploys on 1 October, an hour apart, as the warm-up went from one host to four to all eight. The deploy in the middle, with four hosts warmed and four not, shows why every host needed the warm-up. Each warmed host recovered faster on its own: mean request duration peaked at 130 to 173 ms, against 203 to 311 ms on the hosts that weren’t warmed. But the backlog on all eight was about the same, because they were all waiting on the same database. Mean request duration on each host at the 07:21 UTC deploy. Blue hosts warmed up in the master before forking, orange hosts did not. Memory came down too. The workers now share compiled templates, YJIT code and the schema with the master instead of each building their own copy. On beta, the view precompiler alone took a busy worker’s private memory from 174–202 MB to 119–135 MB. Thirty minutes after the deploy, the web containers used about 39 GB less memory than the previous day’s containers at the same age and traffic. Amsterdam served most of our traffic at the times we tested. In Amsterdam, each new container used about 2 GB less just after traffic moved to it, which lowers the peak while the old and new containers overlap. Working with Claude Claude Code helped throughout. It combed through the per-worker backlogs and per-endpoint timings in Prometheus and Loki after each deploy, worked out the cold-versus-warm cost of each endpoint, and prepared the changes and the pull request descriptions with the benchmarks in them. We decided what to try, deployed it and read the results. If you do this Warm the master before it forks. Compile common code and templates and load their schema in the master, so workers inherit that work. With preload_app!, Rack::Builder#warmup runs before the workers start accepting traffic. Use the app’s real requests. Public pages, internal endpoints and synthetic queries warm the paths they run and nothing else. Signed-in requests to real records, frames included, run what production runs. Measure the cold penalty per endpoint. The difference between an endpoint’s cold and warm duration, times its request count after a deploy, ranks the pages worth adding. Ours weren’t the ones we’d have guessed, and two of them were JSON. Check what the warm-up leaves open. List the master’s file descriptors after a warm-up and account for every one before the fork. Two of ours needed changes. Set a time budget. A warm-up that runs long on one slow host fails the deploy on that host. Ours gives the page requests and schema loading a shared 20-second budget, checked before each page or model, puts the most valuable pages first, and logs what it skipped. Reforking from a warm worker, as Pitchfork does, solves the same problem continuously rather than once at boot, and it would warm paths no fixed list of pages covers. We may still get there: our branch brings Instacart’s mold_worker up to date with Puma’s main branch and fixes the bugs we found in it. But warming the master works with the Puma we already run, took a few days to implement, and substantially reduced the queues after deployment.

1st Oct 2026 • 1 votes
Lexxy 1.0 is here

Today we are releasing the version 1.0 of Lexxy. Lexxy is a rich text editor for Rails built on Lexical. It already powers Basecamp, Fizzy and many others, and it will become the default editor in Rails. I recently presented it in Rails World (slides, video coming soon). This is the article version of my talk. Trix hit a wall Trix has been our editor since 2015, and every Rails app’s editor since Action Text shipped in Rails 6. It’s small and reliable, and it has served millions of people for a decade. But in the last few years our customers kept asking for features like tables or code highlighting, and we kept struggling to deliver them. The reason is the Trix document model. A Trix document is a flat list of blocks. A block is a line of text with some labels attached, like quote, bullet list, bullet. There is no tree, and a block can never contain another block. Nesting is an illusion: at render time, adjacent blocks with the same labels get wrapped together. A flat list of blocks with very limited extensibility options That design bought a lot of simplicity, but you can’t express something like a table with it. Two cells next to each other would carry exactly the same labels, so Trix would merge them into one. The model can say “this bullet is one level deeper”. It cannot say “this cell is different from the cell next to it”. A tables issue has been opened since 2015! The model just can’t do it. The second problem was maintenance. An editor built on contenteditable behaves differently in every browser and even changes from time with operating system releases. In 2024, three iOS releases in a row broke typing, dictation or the caret in Trix, and each one cost us real effort to work around. Check this one as an example. Why Lexical This was a conversation we had at 37signals for years: 2022. We started a project to add tables to Trix. We gave up after a week. The document model can’t represent two-dimensional things. 2023. I built a proof of concept with Tiptap inside HEY. We liked it, but we never started a serious project with it. 2024. We built House, our own Markdown editor, for Writebook. Not WYSIWYG, but WYSIWYM: what you see is what you mean. A wonderful editor for long-form writing like books or technical documentation. 2025. We tried House in another product, and it didn’t fit. For most apps, WYSIWYG was just the right answer. 2025. We had the discussion again, and this time we looked at the whole field. Four years of the same conversation David ruled out Tiptap, CKEditor and the other commercial editors: an open source core with features kept proprietary, and a sales team behind them. We didn’t want our editor to depend on somebody else’s licensing decisions. Then we found Lexical: MIT, from Meta, and very powerful. I spent two weeks evaluating it, and in May we made the call to go with it. A tiny core. Pick the rest. Lexical’s core has zero dependencies and weighs forty-two kilobytes. In a way, it validates the approach that Trix pioneered. The document is an immutable state you never mutate directly, you just get new snapshots when performing updates; contenteditable is an input device and a rendering surface, never the source of truth. It has a DOM reconciler to update the actual DOM very efficiently, and other primitives to deal with handling commands and node transformations. Everything else, from lists to tables to markdown, is a package in the orbit. Lexxy uses thirteen of them. Lexical solved the maintenance problem too. Meta’s products like Facebook or Instagram use Lexical and their user count is in the hundreds of millions. This means that even small issues with new keyboards and devices are fixed fast by the dedicated Meta team that maintains it. Furthermore, Meta’s business is the products, not the editor. We much rather liked this structure of incentives for the long-term investment an editor represents. The iceberg The plan was simple. Pick Lexical, wire it up to Action Text, add a toolbar and ship it. Well, it didn’t go exactly like that. What you envision, and what's under the water Lexxy today is thirteen thousand lines of vanilla JavaScript on top of Lexical. A great editing experience is very hard to get right. An editor is a machine where the user can change the state in a thousand different ways. For example. you have two images one after the other and want to put the cursor between them, but there is nothing there to put a cursor in. Or somebody pastes from Google Docs, and you have to turn a pile of inline styles and empty spans into clean markup. And then Safari, and Android keyboards, and the clipboard, and undo, and… From the first pull request to Basecamp 5 The first pull request landed in May 2025. Fizzy launched with Lexxy in December, and Basecamp 5 in May this year. Basecamp was the real test: twenty years of content written with Trix, and people who use the editor all day, every day. Zoltán Hosszú and Samuel Péchèr were the key people who made this happen. The took a very green version of Lexxy, added a ton of features (including Tables) and polish, and they fixed countless bugs. They also pulled off a remarkable milestone: seamlessly switching millions of Basecamp users from Trix to Lexxy. What’s included? We didn’t want a to build Trix with tables. We had Lexical and we had agents to help, so we wanted to be ambitious here. We went for the whole package. Features In terms of major features: A color highlighter, built in instead of this being a custom Basecamp extension, as it was with Trix. Tables, with an interface we worked hard to keep simple and accessible. Markdown. You type it, you get rich text. Code blocks with syntax highlighting as you type, in more than twenty languages. Image galleries you can navigate and reorder with the keyboard. Prompts. Type a character, get a menu: mentions, emoji, or whatever your app needs. Links by pasting a URL over selected text. Previews of attachments like videos and PDFs, rendered as your app renders them. Action Text Native Lexxy is also Action Text native. Action Text stores attachments in a canonical format that Trix doesn’t speak, so it translates on save and again on render. We taught Lexxy to emit exactly that markup. What you see in the editor is what gets saved, and what gets saved is what your app renders. Your existing content, attachments and views keep working. That opened another door. Action Text now talks to an editor adapter, with an implementation for Trix and one for Lexxy, so switching is one line: config.action_text.editor = :lexxy. Here the credit goes to Sean Doyle, who started that pull request before Lexxy existed and took it to the finish line with our input. It ships with Rails 8.2, and we hope other editors will use it too. Extensibility And you can extend Lexxy. Extensions are built on Lexical’s own mechanism, and this is not a second-class API: Lexxy itself is thirteen extensions, tables included, and Basecamp has nine more. class MyExtension extends Lexxy.Extension { get enabled() { … } get allowedElements() { … } get lexicalExtension() { return this.defineExtension({ name: "my-extension", nodes: [ … ], register(editor) { … } }) } initializeToolbar(toolbar) { … } dispose() { … } } Lexxy.configure({ global: { extensions: [ MyExtension ] } }) My favorite of how extensible is Lexxy are voice notes in Basecamp: you record, you see the waveform while you talk, and it becomes a player inside the document. About a thousand lines, without forking or patching anything. Performance Lexxy is fast, because Lexical is fast. In a ten thousand word document, Trix takes thirty-eight milliseconds to process a keystroke. Lexxy takes four. Above fifty milliseconds, the editor starts feeling sluggish. Compared to Trix, Lexxy brought a whole new performance regime. Milliseconds per keystroke by document size Accessibility Accessibility in Trix was not great. In general, building accessible experiences for rich text editors built on top of contenteditable is quite hard. We had a dream team to help with Lexxy accessibility. Bruno Prieto worked with Michael Berger, our accessibility champion at 37signals, to bring the bar to where we wanted it to be. Bruno is an outstanding programmer who happens to be blind, so he knows one thing or two about accessibility, and he delivered. As a result, in Lexxy everything is reachable with the keyboard. The editor announces itself properly to screen readers, and it gets a thousand details right so that the editing experience using a screen reader is fantastic. You can learn more about accessibility in our docs. Security The latest AI models have resulted in an unprecedented explosion of vulnerabilities found, and we took this thread quite seriously. Lexxy counted with programmers of the caliber of Jeremy Daer and Mike Dalessio helping to make it more secure. We have put a lot of attention to sanitizing the editor contents, validating attachment URLs and making sure that the types of attachments and nodes the editor support are allow-listed. Lexxy also comes with preliminary Trusted Types support, to offer CSP-level control over certain DOM manipulation APIs. The trusted types policy is there, but we are not enforcing it everywhere yet. Agents We started Lexxy using Claude since day one. A main lesson was that an agent needs to drive the editor like a user does. The best decision we made in this project was moving the system tests from Capybara to Playwright: three browsers instead of one, a suite that runs in seconds, and a much more faithful clipboard, keyboard and focus. This represented a tremendous improvement in how agents could close the loop by themselves. Write a test, see it fail, fix it, see it pass. We have more than six hundred browser tests today. Moving the suite to Playwright changed how fast we could write tests With a solid testing foundation in place, we could start fixing bugs in large batches. As mentioned, getting a text editor right implies a ton of work, and the kind of backlog we got at some point would have have buried us in pre-agent times. We also used agents to validate the fixes: an agent reproduces the bug in the public Lexxy sandbox, checks that it’s gone with the branch applied, and labels the pull request. Agents were essential to get Lexxy done with the people and the deadlines we had: we are a small company, and the same people were shipping two products in parallel. 275 cards closed The new Rails default We believe Lexxy is the best rich text editor out there right now, and we are going to make it the default editor in Rails next. If you’re starting a Rails application today, use Lexxy. If you’re using Action Text with a standard configuration, switch. It’s one line, and we’ve worked hard to make it seamless.

28th Sep 2026 • 1 votes
Introducing Action Push Native

Note: Shortly after releasing this gem, we renamed it from Action Native Push to Action Push Native, in case you arrived here looking for the gem of the former name. More details here. We’ve open-sourced Action Push Native, a Rails gem for sending push notifications to mobile platforms. It supports both Apple and Google push notification services. Why did we build it? We created it to migrate off Amazon SNS and Pinpoint, as part of our broader cloud exit. We’re using it in Basecamp and HEY to send more than 10 million push notifications per day without a hitch. Action Push Native relies on HTTP/2 persistent connections to the Apple Push Notification service, which significantly reduced job duration compared to our previous HTTP/1 setup with AWS Pinpoint: AWS Pinpoint jobs duration Action Push Native jobs duration How does it work? The gem connects directly to the Apple (APNs) and Google (FCM) push notification services. It handles retries, rate-limiting, and deleting dead devices automatically. Configure each platform with your credentials, and you can start sending notifications like this: device = ApplicationPushDevice.create! \ name: "iPhone 16", token: "6c267f26b173cd9595ae2f6702b1ab560371a60e7c8a9e27419bd0fa4a42e58f", platform: "apple" notification = ApplicationPushNotification.new \ title: "Hello world!", body: "Welcome to Action Push Native" notification.deliver_later_to(device) Version 0.1.0 is available now. You can read more on GitHub. We hope you find it useful!

18th Aug 2025 • 1 votes
Announcing Hotwire Native 1.2

We’ve just launched Hotwire Native v1.2 and it’s the biggest update since the initial launch last year. The update has several key improvements, bug fixes, and more API consistency between platforms. And we’ve created all new iOS and Android demo apps to show it off! A web-first framework for building native mobile apps Improvements There are a few significant changes in v1.2 that are worth specifically highlighting. Route decision handlers Hotwire Native apps route internal urls to screens in your app, and route external urls to the device’s browser. Historically, though, it wasn’t straightforward to customize the default behavior for unique app needs. In v1.2, we’ve introduced the RouteDecisionHandler concept to iOS (formerly only on Android). Route decisions handlers offer a flexible way to decide how to route urls in your app. Out-of-the-box, Hotwire Native registers these route decision handlers to control how urls are routed: AppNavigationRouteDecisionHandler: Routes all internal urls on your app’s domain through your app. SafariViewControllerRouteDecisionHandler: (iOS Only) Routes all external http/https urls to a SFSafariViewController in your app. BrowserTabRouteDecisionHandler: (Android Only) Routes all external http/https urls to a Custom Tab in your app. SystemNavigationRouteDecisionHandler: Routes all remaining external urls (such as sms: or mailto:) through device’s system navigation. If you’d like to customize this behavior you can register your own RouteDecisionHandler implementations in your app. See the documentation for details. Server-driven historical location urls If you’re using Ruby on Rails, the turbo-rails gem provides the following historical location routes. You can use these to manipulate the navigation stack in Hotwire Native apps. recede_or_redirect_to(url, **options) — Pops the visible screen off of the navigation stack. refresh_or_redirect_to(url, **options) — Refreshes the visible screen on the navigation stack. resume_or_redirect_to(url, **options) — Resumes the visible screen on the navigation stack with no further action. In v1.2 there is now built-in support to handle these “command” urls with no additional path configuration setup necessary. We’ve also made improvements so they handle dismissing modal screens automatically. See the documentation for details. Bottom tabs When starting with Hotwire Native, one of the most common questions developers ask is how to support native bottom tab navigation in their apps. We finally have an official answer! We’ve introduced a HotwireTabBarController for iOS and a HotwireBottomNavigationController for Android. And we’ve updated the demo apps for both platforms to show you exactly how to set them up. New demo apps To better show off all the features in Hotwire Native, we’ve created new demo apps for iOS and Android. And there’s a brand new Rails web app for the native apps to leverage. Hotwire Native demo app Clone the GitHub repos to build and run the demo apps to try them out: iOS repo Android repo Rails app Huge thanks to Joe Masilotti for all the demo app improvements. If you’re looking for more resources, Joe even wrote a Hotwire Native for Rails Developers book! Release notes v1.2 contains dozens of other improvements and bug fixes across both platforms. See the full release notes to learn about all the additional changes: iOS release notes Android release notes Take a look If you’ve been curious about using Hotwire Native for your mobile apps, now is a great time to take a look. We have documentation and guides available on native.hotwired.dev and we’ve created really great demo apps for iOS and Android to help you get started.

23rd Apr 2025 • 71 votes

More in programming

My new digital home

It's time for a dev blogger's favorite topic: blogging about my new blogging setup. After migrating my blog from Hexo to Eleventy, building a photography website and creating a personal one-pager, it was time to find a new digital home for all my projects. If you notice any issue after the migration, please let me know. I redirect the RSS feed to the new domain, but you might want to update the feed URL in your reader. Motivation I've been maintaining three different websites under two different names: a mostly developer-oriented blog, a photography portfolio and a one-pager gluing everything together. When I started blogging about my sabbatical journey, I was thinking about making yet another page to create a clear separation. I've decided to do the opposite and merge all my websites into one instead. As I further reduce my dependency on social media and third-party services, it has become even more important to have my own space and practice POSSE. Every decision I've made was driven by two main goals: simplicity and personality. Setup I've stopped chasing the latest and greatest. I want to keep the setup as clean as possible, which means pulling fewer dependencies. Every package adds complexity and maintainability costs, so I've only added what was necessary — no more Gulp, Sass or Tailwind. Instead of taking one of my existing websites as a starting point, I created this project from scratch. I reused some of my previous code, but I didn't want to take over any technical debt (especially with the advancements in CSS). The foundation of this website hasn't changed: Eleventy (Build Awesome). It covers the fundamentals of a static site and offers a great level of customization. I've made my project more and less dependent on Eleventy at the same time: I replaced Gulp as my build pipeline with pure Eleventy processing. I replaced most shortcodes with Markdown alternatives (and markdown-it plugins), which are better supported by editors like Obsidian. I've also replaced the Nunjucks template engine with Liquid, which comes with a few trade-offs. The main reason was to avoid creating a new collection for every set of posts I render. With Liquid, you can apply array functions like filtering or sorting in the template itself: {% assign drafts = collections.all | where: "data.isDraft" | reverse %} {% for draft in drafts %} … {% endfor %} Another advantage is having date formatting included, so I don't need another library or custom code. You can even set a default date format, so you don't have to provide it each time. What I don't like is the verbosity of the template language, although I've gotten used to it by now. Nunjucks: {{ "/tags/" + tag + "/" }} {{ modified or created or date }} {% set perspectives = ["dev", "photography", "life"] %} Liquid: {{ "/tags/" | append: tag | append: "/" }} {{ modified | default: created | default: date }} {% assign perspectives = "dev, photography, life" | split: ", " %} After migrating my existing content, I've noticed the build taking way too long, both in the dev server and the production build. I've found out that the Eleventy template cache is turned off for Liquid by default. This was useful during development, but when updating actual content, it caused every template and partial to be constantly recalculated. After turning the cache on, this issue was resolved: eleventyConfig.setLiquidOptions({ cache: true }); Check the colophon for the full tech stack overview. Perspectives The main challenge was finding a way to separate and present my main personas. The Venn diagram intersection of people interested in software development, photography and my personal life is probably small. I could use tags, but they don't offer the level of personalization I was looking for. Most personal blogs separate the content by post type (blog post, note, photo), but this doesn't fit my use case. Instead, I've decided to structure my website around three perspectives, a term I have borrowed from the Eclipse IDE. Each perspective comes with its own avatar, tagline and bio. If someone is interested in everything I share (hi Mom!), they can still view all my posts. Design Since building my photography website, I've made many changes, but I never got around to share an update. The biggest change was the design, which I have used as a blueprint for the new website. I've decided to mimic a social timeline/feed, which most people are familiar with. At the same time, it should be clear that it's a personal website and not yet another walled garden. Typography is an important part of every design. The first step is to choose between a system font stack and a custom font, which comes with the usual performance vs. personality trade-offs. I've decided to go with a uniform look across all devices. I've settled on Source Sans 3 for copy text and Hack for code. Another big change was introducing a sidebar. I always felt like the empty horizontal space was "wasted" on a desktop device, but getting the sidebar UX right is challenging. I'm happy with the result, especially with the last two panels adding a personal touch to the design. Images I've made several changes to the handling of image files. To make them compatible with Markdown editors, I had to replace my custom picture shortcode. The official Eleventy image plugin is a popular choice, but it violates my simplicity goal and doesn't support everything I needed. I've decided to go with a plain Markdown image markup and a single image file (no format fallback or 2x version for retina displays). Additionally, I wrote a markdown-it plugin to turn the optional title parameter into a caption. As for the file format, both WebP and AVIF have excellent browser support. For each image, I select the format based on the quality and file size. For my photos, I'm using WebP for the timeline thumbnails and JPG for the full-size version. Theme picker There have been some recent discussions about dark mode toggles, and I considered going for a light/dark solution based on the user's OS preference. Driven by the personality goal, I knew I had to add at least one more theme, so a proper theme picker was necessary. Similar to navigation, people like to hide the theme selection on mobile devices behind a burger menu or a dropdown. I prefer the solution by Sarah L. Fossheim, where all available themes are displayed above the page footer. A friend was scrolling through my new website, and their first question was "Why is the theme picker in the footer?". This tells me the solution was a good idea — I don't think he would have noticed a typical theme button in the header. The implementation follows standard practices: Use the light or dark theme as default based on the user's OS or browser preference. Store a selected theme in the local storage and use it as the single source of truth. Prevent a flash of inaccurate color theme. Make the theme play nicely with bfcache. I might have slightly gone overboard with my third theme, but the nostalgia kept me adding more and more features. Enjoy going back to the 90s: Feeds As browser vendors decided to remove XSLT support, I didn't bother to style my feed. Instead, I've created a new web feeds page, listing all available feeds and a short explanation. I provide one feed for every perspective and one with all my posts combined. I once offered tag-based RSS feeds. This was useful for my sabbatical, but even there I had only a single subscriber. Now, all tag feeds redirect to their main perspective feed. I've also dropped the JSON feed, despite having at least 16 subscribers. Given that every web feed reader supports Atom feeds, I don't see the value of providing a JSON alternative. A JSON feed doesn't add much complexity, but handling different media types (post, photo) and properly escaping the content is one more thing to implement and maintain. Quality assurance I've put a lot of effort into my new digital home, and I want to make sure it stays in good shape. I've included several tools, most running automatically as part of the CI pipeline: eleventyDataSchema — lint Eleventy data values like titles or dates markdownlint — format/lint Markdown files Vale — lint content (grammar, writing style, formatting) Lychee — find broken links (internal and external) Evaluatory — check accessibility and validate HTML Oxlint — lint JavaScript files Eleventy linters are another option to analyze a template's output, but I haven't found a use case for it yet. Not everything can be automated, so I also did the following manually: Test with Chrome, Firefox and Safari Test with Polypane Test with Google Lighthouse Test with a screen reader Test with JavaScript disabled Test with CSS disabled Test at 200% zoom Test with a bigger browser font size Test with custom fonts disabled Test with delayed assets Performance It's difficult to compare the performance of two separate websites with that of a combined one. Some parts got worse, e.g. bigger font files and more CSS. At the same time, I've noticed that gzip compression was turned off for anything but HTML for a few years now (I must have missed it when upgrading nginx) 🙈. One improvement was to implement on-demand assets. For example, the lite-youtube-embed CSS and JavaScript is only loaded if a <lite-youtube> element is present on the page. As before, JavaScript is fully optional (except for interactive demos in individual posts). I've been using a mix of <noscript> and CSS to hide elements that require JS (like the theme picker). I don't like the user experience of pagination, so I avoided it for years. But now that I have almost 300 posts, I've decided to finally add it. I display 30 posts per page, with images further down still being lazy-loaded for better performance. I haven't applied pagination to tag pages, as that's more complex with Eleventy. Bits and pieces Here are some other noteworthy changes: I've removed post-specific social share images and use a single WebP Open Graph image instead. I've dropped JSON-LD. Open Graph and microformats are sufficient. "Naming is hard" is a common expression in software development, but it also applies to photograph titles. I've decided to use a counter as the title, e.g. #0100. Sometimes I do come up with a title that I love, like the Castle in the Sky, so I might mention it in the description. On a photo page, you can now open the previous/next photo using the left/right arrow keys (in addition to the existing navigation buttons). It's now possible to filter photos by date, gear, or location. Check the explore page for an overview. I've learned about and used some experimental CSS features (with progressive enhancement), like scroll-triggered animations or squircle corners. I've added an AI statement on my LLM usage. I finally have a proper about page. Cool URIs don't change, but it's inevitable when moving to a new domain. All my previous URLs are redirected, except for some assets that are no longer used. I will keep the old domain, so the redirects will continue to work for a foreseeable future. I've kept my previous Easter egg and added a few more. Have a happy hunt! 🕵️‍♂️ Inspiration I visited hundreds of personal websites looking for inspiration. Here are a few that I kept coming back to: Emma Goto. I love Emma's approach to presenting the variety of her interests, even though I went a different route. Brennan Kenneth Brown. This website has so much to offer. It's a great example of what the open web looks like. Rex Barrett. The sentence "Collecting hobbies is my hobby." hit home — so much that I had to steal borrow it for my short life bio. Conclusion This project was mostly about merging the content of two websites into one. I knew this would take a considerable amount of time, but I still greatly underestimated the effort. In the end, it was all about the journey, not the destination. I loved fine-tuning the design and tweaking little details that probably no one will ever notice. I still have many more ideas left in my backlog. How do you like my new digital home? If you have an opinion, let me know! Reply via email

4 hours ago • 1 votes
Industrial Growth

New electronica album by Warrington-Runcorn New Town Development Plan

4 hours ago • 1 votes
Verifying my web archives with Playwright

To protect against link rot and disappearing content, I save local copies of web pages I like into my web archive. Ideally, this archive is completely self-contained. I want a local copy of each page’s HTML and all the assets it depends on (CSS, images, scripts, and so on). I don’t want my archived pages fetching resources from the original website, which could disappear at any time. Initially, I was verifying self-containedness by hand. I’d open each saved page in a web browser, and check the browser dev tools. I’d check the Console tab for errors and failed requests, and watch the Network tab for requests going out to the internet. That approach was tedious and error-prone, and I wanted something better. I now automate the process using Playwright, a testing framework that allows you to control web browsers. I open each page using Playwright, and check if it’s making external requests. Detecting external requests in Playwright When you open a page in Playwright, you can subscribe to request events which tell you when the page issues a request. In this Python snippet, we start Playwright, start a WebKit browser, and open a new page. We tell the page to append external requests to a list, and then load example.com. We see that example.com makes two requests on page load: one for https://example.com, and one for a JavaScript file. from contextlib import closing from playwright.sync_api import sync_playwright with ( sync_playwright() as p, closing(p.webkit.launch()) as browser, closing(browser.new_page()) as page, ): requests = [] page.on("request", lambda msg: requests.append(msg)) page.goto("https://example.com") print("requests:", requests) # requests: [<Request url='https://example.com/' method='GET'>, # <Request url='https://example.com/s.js' method='GET'>] In a more complex scenario, we might use Playwright to interact with the page after loading – for example, clicking a button or filling in a form, and then we could see the requests triggered by each action. Once we have a list of requests made by the page, we can inspect the url property to check if any of them were fetching from an external website. I wrote a function is_external_request to carry out this check: from playwright.sync_api import Request import urllib.parse def is_external_request(req: Request, original_url: str) -> bool: """ Check if a request has a different origin than the original URL. A request is considered internal if: - it matches the original URL exactly - it has the same scheme, host, and port as the original URL - it uses a `blob:` scheme (for example, in-memory media representations) """ if req.url == original_url: return False # Compare the scheme/host of the request to that of the original URL. org_url = urllib.parse.urlsplit(original_url) req_url = urllib.parse.urlsplit(req.url) if org_url.scheme == req_url.scheme and org_url.netloc == req_url.netloc: return False # Ignore blob: URLs, which are in-memory representation of some data, # and not an external request. # # Playwright detects blob: URLs when loading media files; for example # using the <audio> or <video> tag. if req_url.scheme == "blob": return False # Fail open: if we can't decide if a request was internal or external, # assume it was external. return True assert_page_is_self_contained(ctx, url) I’ve wrapped these two steps in a function assert_page_is_self_contained(ctx, url), which is part of my chives utility library. The function takes a Playwright Browser (or BrowserContext) and a URL, then: Opens a new page in the browser Loads the URL in the page Checks what happened when the page completes loading It throws an exception if there are any: Uncaught page exceptions or errors Console warnings or errors Non-GET requests Requests targeting external resources The function tries to trigger important network requests that might be skipped on the initial page load. For example, it evaluates the body’s offsetHeight to force a synchronous reflow, which causes the browser to request any lazily loaded images or fonts that might otherwise be missed. Here’s an example which verifies a file example.html from the working directory: from chives.browser_fixtures import assert_page_is_self_contained, file_uri from contextlib import closing from playwright.sync_api import sync_playwright with ( sync_playwright() as p, closing(p.webkit.launch()) as browser, ): assert_page_is_self_contained(browser, url=file_uri("example.html")) I originally passed a Page as the first argument, but changing the function to take a Browser or BrowserContext lets it manage its own pages. It gets a fresh page for every verification, and closes it when it’s done. (The first time I ran this, I created a new page for every verification, and never closed them – my computer ground to a crawl as my script opened hundreds of browser tabs.) If you create a new context, you can emulate different browser settings, like location, locale, and window size. Here’s an example where we use contexts to test the same page in light/dark mode: with ( sync_playwright() as p, closing(p.webkit.launch()) as browser, ): with closing(browser.new_context(color_scheme="light")) as light_ctx: assert_page_is_self_contained(light_ctx, url=file_uri("example.html")) with closing(browser.new_context(color_scheme="dark")) as dark_ctx: assert_page_is_self_contained(dark_ctx, url=file_uri("example.html")) In my web archive, I check every page in light/dark mode, and at 1×/2× pixel densities. It takes at least a second to run each verification, so I cache the results. As I save new pages, they’re added to a verification queue, and I’m warned of any missing assets. Unsurprisingly, the first run flagged many missing fonts and images, which I added to my local archive. Every saved page now passes cleanly. Browser is best-effort, but may still miss things Even with automated testing, it’s difficult to guarantee a completely self-contained archive. Different instances of a browser might load different assets, and a single page load might miss different variations: Responsive images – web pages can offer different sizes of an image, and the browser chooses a specific size based on the current window dimensions Appearance settings – a browser might load different fonts or images depending on light/dark mode Interaction-dependent assets – a web page might have assets that are only required on hover or user action You can mitigate some of this by testing with multiple contexts or adding post-load interactions, but every step makes the process take longer. Fortunately, most of the pages I save are simple, blog-style pages where I’ve stripped out the JavaScript. For my specific use case, initial load verification is more than good enough. Other uses for this code There are other projects where it could be useful to intercept and audit the requests made by a web page: Verifying your Content Security Policy (CSP). The CSP header restricts what resources can be loaded by a web page, and you could check that your configuration has the correct effect. Auditing login screens. You could check that sensitive pages don’t load any external scripts, which mitigates the risk of credential theft via a compromised third-party. Testing analytics and tracking. You could script user flows, like a shopping cart checkout, and confirm that tracking events fire correctly. I don’t do much front-end web development, but Playwright has become a key tool in my toolbox. I read a lot about people using AI agents and MCPs to interact with websites, but I prefer Playwright. It’s more fiddly and requires explicit instructions, but it’s constrained, deterministic, and I never have to worry about it doing something unexpected. [If the formatting of this post looks odd in your feed reader, visit the original article]

6 hours ago • 1 votes
Print Sales, Costs, And Profit: 2026

It’s four years since I bought a printer. I thought I would provide an update on running costs, print sales, and profit. I’m happy to be completely transparent about all this as I know it can be difficult to figure this out if you are thinking of creating your own printing business. January 11th 2026, 11:30am All the photos here are work shot in the last twelve months. Year Four Investment? Nothing other than consumables this year. I did purchase a high-spec film scanner last year, detailed here, but don’t consider that as part of this. The scanner has paid for itself several times over, and it may become a factor in future years (see below). Income Income is made up of sales through a local gallery that I am part of, along with web sales: 5,940.- CHF - sales through the gallery 80.- CHF - sales through web shop = 6,020.- CHF total income Total income is down about 50% compared to 2025. Costs I had to purchase consumables (paper and ink), at a total of 957.- CHF. That cost is included in the total here under the “print consumables” category. January 11th 2026, 3:30pm Rental cost of space in the gallery was reduced due to having overpayments of rent and my loan to the gallery, from the previous year(s), paid back to me. -1,500.- CHF - rental cost of space in the gallery -957.- CHF - print consumables (paper, ink) -1,138.- CHF - framing/mounting/postage -1,135.- CHF - commission to the gallery -300.- CHF - web shop subscription 0.- CHF - stripe / paypal fees 0.- CHF - advertising (none this year) = -5,030.- CHF total costs Total costs are down by about 60% compared to 2025. Total Profit 6,020.- CHF total income -5,030.- CHF total costs = 990.- CHF total profit A Quiet Year? Actually an average year. The previous couple of years were outliers, due to large orders, so really I’m back to normal in terms of sales. The key factors in managing to generate a [small] profit this year were: 1) no longer having to pay back the loan used to finance the printer, and 2) reduced rental cost of space in the gallery due to a previous loan to it being repaid. Also having to spend almost 1,000.- CHF on ink this year ate into profit. This is probably a “once every three years” cost given the rate I print at. The principal cost is rental of space in the gallery - in a normal year this would be 3,000.- CHF. Essentially this means, given the material cost of printing and framing/mounting and commission to the gallery, I need to make about 5,500.- CHF of sales to break even. Solar Eclipse, August 12th 2026 That’s a grind. However I did receive two substantial orders at the end of October, which haven’t made it in to this year’s figures and should boost next year’s. Maybe even a good sign for the year to come? At least now the original investment loan is repaid, the printing costs cover themselves in most years. I may look at offering scanning services now that I have a high-spec film scanner, which could become a scan + print service. More thoughts about that next time.

20 hours ago • 1 votes
F1

George Russell driving down the main straight during FP2.

20 hours ago • 1 votes
📚 BoredReading

You seem to be enjoying this.

Join free to unlock everything.

Create free account

Already have an account? Sign in