Why Your wp_remote_post Isn’t Sending CF7 Data to Your API (And How to Fix It)

Why Your wp_remote_post Isn

Why Your wp_remote_post Isn't Sending CF7 Data to Your API (And How to Fix It)<br>
A developer posted this exact problem on the WordPress support forums: they followed a YouTube tutorial step-by-step, hooked into wpcf7_before_send_mail, built a wp_remote_post call and nothing arrived at the external API. No errors, no data, just silence.
This is more common than you’d think. Let’s dissect exactly what went wrong in that code and how to fix it with a no-code alternative at the end if you’d rather skip the debugging rabbit hole.

The Original Code (And What’s Broken In It)
Here’s the code from the forum post, cleaned up for readability:

add_action( 'wpcf7_before_send_mail', 'blog_cf7_api_sender' );

function blog_cf7_api_sender( $contact_form ) {
    $form_id = ( $contact_form )->id();

    if ( $form_id == '02d6ca7' ) {
        $submission = WPCF7_Submission::get_instance();

        if ( $submission ) {
            $posted_data = $submission->get_current();

            $name        = $posted_data['client-name'];
            $phone       = $posted_data['client-phone'];
            $stream_code = [4yv1];   // ← syntax error
            $token       = [12345];  // ← this is an array, not a string

            $url = 'https://coolapi.com';

            $args = array(
                'body' => json_encode(array(
                    'name'        => $name,
                    'phone'       => $phone,
                    'stream_code' => $stream_code,
                    'token'       => $token,
                )                    // ← missing closing parenthesis for json_encode
                'headers' => array(
                    'Authorization' => 'Bearer $token',  // ← single quotes = literal string
                    'Content-Type'  => 'application/json',
                )
            );

            $response = wp_remote_post( $url, $args );
        }
    }
}

There are five bugs in this snippet. Let’s go through each one.

Bug 1: [4yv1] Is Not Valid PHP

$stream_code = [4yv1];

PHP’s short array syntax [] expects valid expressions inside. 4yv1 is neither a string, integer, nor a variable it’s a bare word starting with a digit, which is a parse error. This would cause a fatal PHP error before wp_remote_post ever gets called.

Fix:

$stream_code = '4yv1'; // string
// or if it's meant to be a variable: $stream_code = $some_variable;

Bug 2: $token Is an Array, Not a String

$token = [12345];

This creates a PHP array [12345], not the integer or string 12345. When you later try to interpolate it into a Bearer header, it won’t behave as expected.

Fix:

$token = '12345'; // string token

Bug 3: Missing Closing Parenthesis for json_encode()

$args = array(
    'body' => json_encode(array(
        'name'  => $name,
        'phone' => $phone,
    )              // ← closes the inner array(), but json_encode() is never closed
    'headers' => ...

The json_encode() call is never closed with ) before the comma. This is another parse error.

Fix:

'body' => json_encode(array(
    'name'  => $name,
    'phone' => $phone,
)),  // ← closes json_encode() AND the 'body' key

Bug 4: ‘Bearer $token’ in Single Quotes

'Authorization' => 'Bearer $token',

In PHP, single-quoted strings do not interpolate variables. This sends the literal string Bearer $token to the API not the token value.

Fix:

'Authorization' => 'Bearer ' . $token,
// or with double quotes:
'Authorization' => "Bearer $token",

Bug 5: The Hook Only Fires When Email Sending Is Active

This was the only hint from the WordPress forum moderator, but it’s worth expanding on. wpcf7_before_send_mail is triggered as part of the mail-sending pipeline. If your CF7 form has email sending disabled (or the form tag doesn’t have a mail configuration), this hook never fires silently.

You can verify this quickly:

add_action( 'wpcf7_before_send_mail', function( $contact_form ) {
    error_log( 'Hook fired for form: ' . $contact_form->id() );
});

Check your server logs. If nothing appears, email sending isn’t active for that form.

Alternative hook that always fires regardless of mail settings:

add_action( 'wpcf7_submit', 'blog_cf7_api_sender', 10, 2 );

The Corrected Code
Here’s the full corrected version:

add_action( 'wpcf7_before_send_mail', 'blog_cf7_api_sender' );

function blog_cf7_api_sender( $contact_form ) {
    $form_id = $contact_form->id();

    if ( $form_id == 123 ) { // use integer form ID, not a string with quotes
        $submission = WPCF7_Submission::get_instance();

        if ( $submission ) {
            $posted_data = $submission->get_posted_data(); // correct method name

            $name        = sanitize_text_field( $posted_data['client-name'] );
            $phone       = sanitize_text_field( $posted_data['client-phone'] );
            $stream_code = '4yv1';
            $token       = 'your-actual-token-here';

            $url = 'https://coolapi.com/endpoint';

            $args = array(
                'body'    => json_encode( array(
                    'name'        => $name,
                    'phone'       => $phone,
                    'stream_code' => $stream_code,
                    'token'       => $token,
                ) ),
                'headers' => array(
                    'Authorization' => 'Bearer ' . $token,
                    'Content-Type'  => 'application/json',
                ),
                'timeout' => 15,
            );

            $response = wp_remote_post( $url, $args );

            // Debug: log the response
            if ( is_wp_error( $response ) ) {
                error_log( 'CF7 API Error: ' . $response->get_error_message() );
            } else {
                error_log( 'CF7 API Response: ' . wp_remote_retrieve_body( $response ) );
            }
        }
    }
}

Two additional improvements in this version:

sanitize_text_field() on user inputs before sending them out
Error logging so you can actually see what’s happening in wp-content/debug.log

A Note on get_current() vs get_posted_data()
The original code calls $submission->get_current(). This method doesn’t exist in recent CF7 versions. The correct method is:

$posted_data = $submission->get_posted_data();

If you’re on an older CF7 version, get_posted_data() was introduced around CF7 5.x. Check your CF7 version before relying on either.

Skip the Code Entirely: Use Contact Form to API Plugin
If you’re maintaining multiple forms, the custom PHP approach gets messy fast especially when tokens rotate, API endpoints change, or you need to add new forms. Every change requires a code deployment.
Contact Form to API is a WordPress plugin that handles all of this through a UI: you configure the endpoint URL, map CF7 fields to JSON keys, set auth headers (Bearer, Basic Auth, OAuth 2.0), and test the connection without writing a line of PHP.
It handles the wpcf7_before_send_mail hook internally, manages JSON encoding properly, and gives you response logs out of the box. Worth considering if you’re building for clients or managing more than one integration.

Debugging Checklist
If your wp_remote_post is still silent after fixing the above:

Enable WP_DEBUG in wp-config.php: define(‘WP_DEBUG’, true); define(‘WP_DEBUG_LOG’, true);
Check the form ID — it should be an integer, not a string like ’02d6ca7′
Verify the hook fires with a simple error_log() call
Test your API endpoint in Postman first with a hardcoded payload
Check for SSL issues — some servers can’t verify external SSL certs; add ‘sslverify’ => false temporarily during testing (not in production)
Check wp_remote_retrieve_response_code() — a 200 with no effect usually means the payload structure is wrong, not the connection

fastjson 0.3.0: A Faster Drop-In ext/json for PHP, Backed by yyjson

// Before
$payload = json_encode($response);
$data    = json_decode($input, true);
$ok      = json_validate($input);

// After
$payload = fastjson_encode($response);
$data    = fastjson_decode($input, true);
$ok      = fastjson_validate($input);

That’s the migration. Search-and-replace json_* for fastjson_*. JSON flags, error constants, and last-error semantics carry across byte-for-byte. The two extensions sit next to each other in the same process; adoption is per call site, not per repo.

On the simdjson_php canonical 14.8 MB corpus, that swap buys 6.06× encode throughput, 2.66× decode, and 5.10× validate against ext/json on the same PHP 8.6.0-dev build. The repo is at github.com/iliaal/fastjson. 0.3.0 shipped yesterday.

Why drop in faster JSON for PHP

ext/json is fine. It’s correct, well-maintained, and tracks the spec. On low-traffic endpoints it isn’t on anyone’s profiler. The cost shows up at scale: any application that calls json_encode and json_decode on every request path eventually finds JSON serialization sitting at the top of a flame graph. API gateways feel it first, then log processors and microservice fan-out paths.

Before fastjson the practical options were two:

  1. Stay on ext/json. Eat the CPU cost.
  2. Reach for simdjson_php. It’s fast but decode-only and not API-compatible; every call site has to be rewritten around its result shape.

fastjson is option three. It’s a native PHP extension that wraps yyjson 0.12.0 (MIT, ~6K LOC of focused C) behind a namespaced API that mirrors ext/json‘s contract. PHP 8.3 minimum; 8.4 and 8.5 supported; coexists with ext/json.

What “drop-in” actually means here

The risk with any “drop-in” claim is that it covers 90% of cases and silently changes behavior on the 10% that matter. So this section is what fastjson does and what it doesn’t:

  • Function signatures track ext/json. fastjson_encode($value, int $flags = 0, int $depth = 512). Same positional shape. Same defaults. Same return values on success, false on failure.
  • JSON_* flags match byte-for-byte. JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, JSON_PRETTY_PRINT, the JSON_HEX_* family, JSON_THROW_ON_ERROR, JSON_INVALID_UTF8_IGNORE, JSON_INVALID_UTF8_SUBSTITUTE. The integer constants are intentionally identical so user code can pass the same flag value into either function.
  • JSON_ERROR_* codes match byte-for-byte, messages don’t. fastjson_last_error() returns the same JSON_ERROR_* int as json_last_error() for the same failure class, so code branching on error codes works without modification. fastjson_last_error_msg() returns yyjson’s parser message (e.g., "unexpected character"), not ext/json’s. Application code that pattern-matches on the message string needs updating; code that branches on the code does not.
  • Coexistence, not replacement. Both extensions load. Migrate the call sites where JSON is on the hot path; leave the rest on ext/json.
  • 53 phpt tests rewritten from php-src/ext/json/tests/*.phpt run alongside fastjson’s own suite. The rest of the upstream suite is categorized in tests/upstream-json/.skiplist with the reason each test was deferred (most are tests of ext/json internals that don’t translate, a few hit known divergences).
  • Documented divergences. Large/scientific doubles emit 100000000000000000.0 where ext/json emits 1.0e+17 in some ranges. U+2028 / U+2029 line separators emit as ordinary code points; ext/json always escapes them for JSONP safety. Both divergences are in the skiplist with rationale.

The numbers

Full simdjson_php canonical corpus: 14.8 MB across 15 files, the same set the simdjson PHP binding has been benchmarked against for years. Hardware: i9-13950HX. PHP 8.6.0-dev, release build (-O2). fastjson built -O2 against the same PHP. yyjson 0.12.0 with three local patches. Numbers in throughput, MB/sec:

Operation fastjson ext/json Speedup
Decode (stdClass) 602 MB/s 227 MB/s 2.66×
Decode (assoc array) 628 MB/s 237 MB/s 2.65×
Encode 1,092 MB/s 180 MB/s 6.06×
Validate 1,352 MB/s 265 MB/s 5.10×

Visual side-by-side, including ext/json with Nora Dossche’s open SIMD encode work (php-src#17734) and simdjson_php on the same PHP 8.6.0-dev build, lives at https://iliaal.github.io/fastjson/baseline.html. Reproduce locally with bench/run.php against any PHP install.

The encode speedup is the largest gap because the PHP-native encoder has the most room to give back. ndossche’s php-src#17734 patch closes a meaningful chunk of that gap inside ext/json itself using SIMD on string encoding. fastjson and that PR attack the same problem from different angles, and an application can benefit from both once #17734 lands upstream (fastjson re-baselines automatically; the visual page already shows both).

How the encoder gets to 6×

The encoder is one-stage. A zval walks straight into a smart_str buffer via yyjson’s writer primitives. There’s no intermediate DOM, no two-pass build-then-serialize, no temporary string allocations for the common-case scalars. Each PHP value type maps to one or two yyjson calls; arrays and objects walk recursively into the same path.

A few less-obvious pieces:

  • Custom allocator wired through Zend. yyjson’s allocator hooks route every alloc/realloc/free through PHP’s emalloc family. JSON workloads count against memory_limit and against per-request memory accounting; oversized inputs bail out the same way any other PHP allocation does.
  • Direct smart_str integration. No RETURN_STRING(estrdup(buf)) after a separate allocation. The yyjson writer writes into the smart_str.s backing store, which becomes the return zend_string directly. One allocation per encode call in the common case.
  • HEX_* flag scan-first. The flags JSON_HEX_TAG/HEX_AMP/HEX_APOS/HEX_QUOT rewrite specific characters into hex escapes. fastjson scans the string once for any candidate character; if none are present, the rewrite path is skipped entirely and the string is encoded directly. Defensive callers that pass HEX_* flags as a precaution on payloads that don’t actually contain the substituted characters don’t pay the rewrite cost.
  • Integer-valued-double shortcut. When a double round-trips losslessly through int64, fastjson emits it as an integer without going through php_gcvt or yyjson’s REAL writer. A cheap range check fires before floor(), so number-heavy arrays of non-integer doubles don’t pay libm per element.

The decode path’s gain has different sources: yyjson itself, which parses with less branching and tighter memory locality than ext/json‘s parser, and a local yyjson patch (YYJSON_READ_VALIDATE_ONLY) that turns the read path into a fast validate-only mode without materializing values.

The memory tradeoff

Worth surfacing before anyone hits it in production. Decode and validate hold the yyjson document object in memory alongside the PHP-side result, because yyjson’s value graph is built first, then traversed to produce zvals. Peak heap on decode is roughly 1.7× what ext/json peaks at on the same input. Encode is one-stage and peaks at ~1.06× of ext/json.

Validate is the loudest: peak heap is ~101× ext/json‘s streaming validator (which sits at a constant ~80 bytes since it doesn’t materialize anything). The headline number sounds extreme, but the absolute footprint is bounded by yyjson’s read path, and it’s already 2.7× lower than yyjson’s stock read path thanks to a vendored validate-only patch (YYJSON_READ_VALIDATE_ONLY) that skips the value-graph build.

For most callers, the speedup wins. If the application is validate-heavy on giant inputs under tight memory_limit, the memory profile is a real consideration. The right move there is to leave validate-on-huge-inputs on ext/json and migrate the encode and decode paths. That’s exactly what coexistence buys.

Compat harness

53 rewritten phpt tests from php-src/ext/json/tests/*.phpt run alongside fastjson’s native suite. They cover the common decode/encode/validate paths, the flag combinations, and the documented error conditions.

The two remaining intentional divergences:

  • Large/scientific doubles. Outside the integer-valued-double shortcut range, fastjson emits yyjson’s real-number format. ext/json uses php_gcvt and switches to scientific notation earlier. The disagreement window narrowed in 0.3.0; what’s left is the genuinely-fractional case where yyjson and php_gcvt produce different decimal representations of the same IEEE 754 double.
  • U+2028 / U+2029 line separators. ext/json always escapes these for JSONP safety. yyjson treats them as ordinary code points. fastjson follows yyjson’s behavior. If JSONP is in the deployment path, set JSON_UNESCAPED_UNICODE off in ext/json and stay on ext/json for that endpoint, or wrap fastjson output through a JSONP-safe post-step.

Upstream collaboration with yyjson

fastjson vendors yyjson 0.12.0 with three local patches. Full details are in vendor/yyjson/PATCHES.md; the short version of each, and what happened when each was proposed upstream:

  • Lowercase hex digits in uXXXX escape table. yyjson emits uppercase; ext/json emits lowercase. RFC 8259 §7 allows either, but byte-parity with ext/json is the project’s compat goal. Proposed as yyjson#264 (YYJSON_WRITE_LOWERCASE_HEX flag) and accepted upstream on 2026-05-11. fastjson will drop this patch once the vendored sources advance past yyjson 0.12.0.
  • YYJSON_READ_VALIDATE_ONLY, no-tree validate mode. Forks yyjson’s parser entry point to skip the value-graph build entirely; peak memory drops 2.7× on the validate corpus. Not yet proposed upstream; the API surface needs a round of review before it’s ready to submit.
  • Public yyjson_write_string_to_buf() wrapper. Exposes yyjson’s internal direct-write primitive so fastjson’s one-stage encoder can compose at the buffer level. Proposed as yyjson#266 and closed upstream; the maintainer preferred to keep that surface private. The wrapper stays vendored locally; fastjson lives with it indefinitely.

Install

pie install iliaal/fastjson

Or build from source:

phpize
./configure
make -j
make test
sudo make install

PHP 8.3 minimum. No external library dependencies; yyjson is vendored in src/yyjson/.

The four-character migration

fastjson stays useful as long as yyjson’s design choices (one-stage encoder, validate-only fast path, allocator hooks) beat what fits into ext/json‘s compatibility envelope. For the call sites where JSON serialization shows up on a flame graph, the migration is four characters; the rest of the codebase doesn’t have to care.

Repo: https://github.com/iliaal/fastjson. Benchmark methodology and reproduction: bench/run.php. Visual baseline: https://iliaal.github.io/fastjson/baseline.html.

I/O 2026: Forget the Glasses. Let’s Talk About the AI Context Nightmare Google Just Fixed.

I’ll be honest: if you watched the Google I/O 2026 keynotes, you probably saw a lot of flashy consumer tech. There was a highly choreographed demo where Gemini used Android XR smart glasses to identify things in the room and play Charli XCX as entrance music.

As a consumer, that’s neat. As a developer? I don’t care. To me, it felt like a glorified Bluetooth microphone that just connects to your phone to do the actual heavy lifting.

What I do care about is the heavy lifting. I care about the backend infrastructure required to make these autonomous “agentic” workflows actually function in the real world. For the past year, building an AI agent has been an absolute architectural nightmare. If you wanted an agent to execute Python code safely or search the web, you had to manually provision secure sandboxes, manage complex execution loops, and constantly shuffle massive arrays of message history back and forth to the LLM just to maintain the conversational context.

That’s why the most underrated and genuinely exciting announcement from Google I/O 2026 wasn’t a piece of hardware or a shiny new IDE. It was a backend structural shift: Managed Agents and the Interactions API.

Here is a first-look guide into why this update fundamentally changes how we build with AI, and why I believe it’s the single most important tool Google shipped this year.

The Overlooked Gem: Managed Agents in the Gemini API

Historically, an LLM was just a stateless text generator. If you wanted it to act like an “agent”—meaning it could plan, use tools, and execute code—you had to build a massive orchestration layer around it.

Google is finally abstracting that away. With Managed Agents in the Gemini API, you can now spin up a fully provisioned agent powered by the new Antigravity harness (running on the lightning-fast Gemini 3.5 Flash model) with a single API call.

What does that actually mean? It means Google hosts an isolated, ephemeral Linux cloud sandbox for your agent. Your agent can autonomously reason, execute code, manage files, and browse the live internet without you having to configure a single Docker container or AWS Lambda function.

Getting Started: A Hands-On Look

Let’s look at how ridiculously simple the implementation is using the new Python SDK (google-genai).

If I want to spin up an agent to do some deep data analysis, all I have to do is tell the API that I want a “remote” environment:

Python

from google import genai  

client = genai.Client()  
interaction = client.interactions.create(  
    agent="antigravity-preview-05-2026",  
    input="Plot the growth of solar energy generation globally and make some slides in HTML.",  
    environment="remote", # This provisions the remote Linux sandbox hosted by Google  
)  
print(interaction.output_text)  

In that single call, the API provisions the sandbox, the agent researches the data via the web, writes the Python code to plot it, executes the code inside the Linux environment, and returns the final HTML.

But what if you want to build a custom agent tailored to your app’s specific domain? Instead of writing hundreds of lines of complex orchestration code, you just define your agent using markdown files and register it with the API:

Python

# Define your agent and register it as a managed agent  
agent = client.agents.create(  
    id="data-analyst",  
    base_agent="antigravity-preview-05-2026",  
    base_environment={  
        "type": "remote",  
        "sources":  
    }  
)  

# Now, just call your custom agent  
result = client.interactions.create(  
    agent="data-analyst",  
    input="Analyze the Q1 revenue data and create a slide deck",  
)  
print(result.output_text)  

The Real Magic: The Interactions API

The sandboxing is great, but the Interactions API is the true hero here. It is explicitly designed to solve the state-management nightmare.

Previously, if a user asked a follow-up question, you had to append it to the entire conversation history and resend the whole massive payload back to the model. With the Interactions API, the core resource is the Interaction object, which acts as a permanent, server-side session record.

It logs the complete chronological sequence of what just happened: the model’s internal thoughts, the function_call to the tools, the function_result, and the final model_output.

Because Google stores this environment and context securely on their end, the sandbox is persistent. Any Git repositories the agent downloaded, pip packages it installed, or files it generated remain entirely intact for the next turn. When the user asks a follow-up question, you don’t resend the history. You simply pass the new prompt and link it using the previous_interaction_id.

My Verdict

The community has been begging for a first-class, provider-agnostic way to handle server-side context and infrastructure abstraction. We are tired of writing boilerplate orchestration code just to get an LLM to reliably trigger a Python script.

While the general tech media is busy talking about AI smart glasses and the “vibe coding” capabilities of the new Antigravity 2.0 desktop app, backend and full-stack developers need to be paying attention to the Interactions API. By entirely removing the friction of infrastructure setup, Google isn’t just giving us a new tool; they are actively transitioning us from being syntax-typists into high-level system architects. We finally get to focus on what our agents do, rather than how they run.

And that is infinitely more exciting than Charli XCX playing from a pair of sunglasses.

How to build a film look in a renderer without just adding grain

A film look does not come from adding grain at the end of a pipeline.

Grain matters, but it only works when it belongs to the rendered image. If the color response is brittle, the grain ignores image density, or particle size only makes noise louder, the result does not become more film-like. It just becomes noisier.

The better question is:

How should color response, image density, and grain particles work together?

That is the renderer boundary I changed in Filmtone Desktop v1.13.

The short version

For this release, I treated film look as a relationship between four parts:

color response
  + image-density response
  + particle scale
  + particle density
  = texture that belongs to the image

Filmtone Desktop v1.13 changes the native renderer in three places:

  • built-in Stone, Urban, and Noir now carry an internal Exposure / Density Color Response stage;
  • grain moved away from direct RGB noise toward a density-domain particle model;
  • Grain Size now feeds an internal Texture Engine:
Texture = ScalingField(image) * ParticleField(seed, params)

No new grain panel. No sidecar schema change. No export workflow change.

The point was to improve the model before adding more controls.

Why “add noise” breaks down

The simplest grain implementation looks like this:

output.rgb = graded.rgb + noise.rgb * amount

That can be useful as a debug view, but it has a predictable visual failure: the noise does not know anything about the image it is being applied to.

On smooth bright surfaces, it can read as a layer of speckle. On skin or saturated colors, independent RGB movement can become colored dirt. In dense shadows, grain can turn into muddiness instead of texture.

The failure is not just amplitude. Turning the slider down hides the problem, but it also removes the texture.

The model needs better boundaries.

1. Color response before grain

If a strong grade already separates color too harshly, grain will amplify the breakpoints.

In Filmtone Desktop v1.13, the built-in Stone, Urban, and Noir paths now include an internal Exposure / Density Color Response stage. This is not a new user-facing control. It is a renderer-stage change inside the built-in preset path.

Imported LUTs and custom grade paths stay neutral unless they explicitly opt into this response in a future release.

The goal is simple: make the color response hold together before particles are added.

2. Grain as a density-domain particle model

The next change was to stop treating grain as unrelated RGB pixels sprinkled over the frame.

The release moves the built-in grain path toward:

mostly luma/density residual
weak correlated dye variation
no independent RGB color dirt
procedural particle structure

This is not a full film-emulsion simulation. Filmtone Desktop v1.13 does not ship scanned grain plate libraries, measured stock profiles, or a claim of physical film accuracy.

The narrower goal is more useful for this release: make procedural grain behave more like image texture and less like an overlay.

3. Splitting where texture appears from what particles are

The larger internal change is the Texture Engine shape:

Texture = ScalingField(image) * ParticleField(seed, params)

ScalingField answers where the texture should be visible. For grain, it is image-dependent: highlights, mid tones, and shadows should not all carry particles the same way.

ParticleField answers what the texture looks like. It owns particle size, density, variation, and stochastic structure.

That split matters because grain size is not the same thing as grain amount.

If increasing Grain Size only makes the noise coarser or louder, bright smooth areas quickly become uniform digital spray. The renderer needs a morphology axis: as particles get larger, particle density should be able to change too.

In v1.13, Grain Size feeds this Texture Engine so the renderer can trade particle density for particle scale. Bright areas can hold fewer, larger particles instead of only gaining more speckle.

The core path looks like this:

graded image
  |
  v
internal color / density response
  |
  v
ScalingField(image)         ParticleField(seed, params)
        |                          |
        +------ texture = scaling * particles
                           |
                           v
                 density-domain grain response
                           |
                           v
                       output image

Why this stayed internal

I did not add more controls for this release because the public UI was not the problem.

The old model could fail even with reasonable control values. Adding more sliders would have asked the user to compensate for renderer behavior that should be handled inside the image pipeline.

So the public surface stayed stable:

  • existing controls;
  • existing saved settings;
  • existing sidecars;
  • existing export workflow.

The renderer changed underneath the built-in Stone, Urban, and Noir paths.

Boundaries

A few boundaries are important.

Filmtone Desktop v1.13 is a public Desktop release, not an iOS release announcement. Filmtone’s iOS public App Store version is a separate release axis.

This release also does not claim parity with named film-emulation tools. It uses familiar ideas from grain synthesis and procedural particle models, but the shipped behavior is Filmtone’s own native renderer path.

Print Material speck and Backlight Veil mottle are not public texture surfaces in this release. The Texture Engine foundation is used for grain first.

Takeaway

If I had to reduce the implementation lesson to one line:

A film look needs grain to be part of image formation, not a noise layer after it.

For Filmtone Desktop v1.13, that meant color response first, density-domain grain second, and a Texture Engine that separates where texture appears from what the particles are.

Small UI release. Meaningful renderer change.

Try Filmtone in your browser

Download Filmtone Desktop for Mac

Healthchecks.io alternatives for developers (2026)

Healthchecks.io is a well-designed tool for one specific problem: alerting you when a scheduled job stops checking in. If that is all you need, it is hard to beat. But most developers eventually need more — cron scheduling, uptime monitoring for HTTP endpoints, or all three in one place. Here is a clear comparison of the best alternatives.

What Healthchecks.io does well

Healthchecks.io is a dead man’s switch for cron jobs. Your job sends an HTTP ping to a unique URL after each successful run. If the ping stops arriving within the expected window, Healthchecks.io sends an alert. The concept is simple, the implementation is clean, and it is open source — you can self-host it for free.

The free hosted tier gives you 20 checks and three months of log history, which is enough for most small projects. Setup takes about five minutes. If you only need heartbeat monitoring and nothing else, it remains an excellent choice.

Why developers look for alternatives

The typical reason is scope. Healthchecks.io monitors whether your job runs — it does not schedule the job, and it does not monitor whether your HTTP endpoints are up. Once you add uptime monitoring for your API and a cron scheduler for your background tasks, you have three separate tools, three dashboards, and three invoices.

Other common reasons:

  • Self-hosting overhead. The open source version requires a server, a Postgres database, and ongoing maintenance. For solo developers this is often not worth the operational cost.
  • Pricing at scale. The hosted Business plan jumps significantly once you need more than 20 checks or additional team members.
  • No cron scheduling. Healthchecks.io tells you a job did not run — it does not run the job for you. Serverless environments like Vercel and Railway make this a common pain point.
  • No API-first workflow. Creating and managing checks programmatically is possible but limited compared to tools built around an API-first model.

Comparison at a glance

Pricing approximate — verify on each vendor’s site before purchasing.

Tool Heartbeats Cron scheduling Uptime monitoring Free tier Starting price
Healthchecks.io ✓ ✗ ✗ 20 checks ~$20/mo
Tickstem ✓ ✓ ✓ 5 heartbeats $12/mo
Cronitor ✓ ✓ ✓ 5 monitors ~$25/mo
Dead Man’s Snitch ✓ ✗ ✗ 1 snitch ~$9/mo
Better Uptime ✓ ✗ ✓ 10 monitors ~$24/mo
Sentry Crons ✓ ✓ ✗ Yes (with Sentry) Free

The alternatives in detail

Best for: developers who need cron + heartbeat + uptime under one key

Tickstem
Tickstem bundles cron job scheduling, heartbeat monitoring, and uptime monitoring under a single API key. The heartbeat implementation is identical in concept to Healthchecks.io — each heartbeat gets a unique token, your job pings the URL after each run, and you get an alert after two consecutive missed intervals.

The difference is what surrounds it. You can schedule the job from Tickstem directly, monitor the HTTP endpoint it hits, and receive all alerts in one place. There is a Go, Node.js, and Python SDK. The free tier includes 5 heartbeats, 5 uptime monitors, and 1,000 cron executions per month.

# Create a heartbeat
curl -X POST https://api.tickstem.dev/v1/heartbeats 
  -H "Authorization: Bearer $TICKSTEM_API_KEY" 
  -d '{"name":"nightly-export","interval_secs":86400,"grace_secs":3600}'

# Ping it from your job
HEARTBEAT_TOKEN="your-64-char-token"
curl -X POST https://api.tickstem.dev/v1/heartbeats/$HEARTBEAT_TOKEN/ping

Pros: cron + heartbeat + uptime in one tool, API-first, Go/Node/Python SDKs, lower entry price than Cronitor.

Cons: newer product, smaller community than established alternatives.

Best for: teams that want a polished all-in-one monitoring platform

Cronitor
Cronitor is the most direct feature-equivalent to the Tickstem bundle — it handles cron job monitoring, heartbeat monitoring, and uptime checks. It has been around longer and has a larger community. The dashboard is well-designed and the alerting is flexible.

The tradeoff is price: Cronitor’s paid plans start higher, and features like Slack alerting and team seats are gated behind more expensive tiers. If you are a team with a monitoring budget, Cronitor is a mature choice. If you are a solo developer or early-stage product, the cost is harder to justify.

Pros: mature product, strong community, comprehensive alerting options.

Cons: higher pricing, some features require paid tiers that are expensive for small teams.

Best for: simple heartbeat monitoring with minimal setup

Dead Man’s Snitch
Dead Man’s Snitch is the closest conceptual equivalent to Healthchecks.io. It does one thing: alert you when a job stops checking in. The free tier gives you one snitch — enough to evaluate the product. Setup is extremely simple.

If you genuinely only need heartbeat monitoring and want the smallest possible tool, Dead Man’s Snitch or Healthchecks.io are the right choices. Neither does cron scheduling or uptime monitoring.

Pros: dead simple, focused, low cost entry point.

Cons: heartbeat-only, no uptime monitoring, no cron scheduling.

Best for: teams that need uptime + heartbeat but not cron scheduling

Better Uptime
Better Uptime covers uptime monitoring and heartbeat checks in a clean interface with strong on-call and incident management features. If your primary concern is whether your API endpoints are responding and your background jobs are running — but you handle scheduling elsewhere — Better Uptime is a solid choice.

It does not schedule cron jobs. Pricing is competitive for small teams but scales quickly as you add more monitors and team members.

Pros: clean UI, good on-call features, uptime + heartbeat covered.

Cons: no cron scheduling, pricing escalates with team size.

Best for: teams already using Sentry

Sentry Crons
If your application already sends errors to Sentry, Sentry Crons is the easiest addition. It monitors whether your cron jobs run on schedule and surfaces missed or failed jobs alongside your error tracking. It is included in Sentry’s free tier.

The limitation is scope — Sentry Crons does not monitor uptime for HTTP endpoints. It also ties you deeper into the Sentry ecosystem, which may or may not suit your stack. But if you are already paying for Sentry, adding cron and heartbeat monitoring costs nothing extra.

Pros: free if you already use Sentry, good cron + heartbeat coverage, integrated with error tracking.

Cons: no uptime monitoring, requires Sentry, limited alerting channels on free tier.

Self-hosted Healthchecks

Worth a separate mention: Healthchecks.io is fully open source and you can run it yourself. If you have a server already and want zero recurring cost for heartbeat monitoring, the self-hosted version is a legitimate option. The setup requires Docker or a Python environment, Postgres, and a mail server for alerts. For solo developers comfortable with server ops, this is hard to beat on cost.

The downside is ongoing maintenance. Every upgrade, database migration, and backup falls on you. For a monitoring tool — something you depend on to tell you when things break — operational overhead is worth factoring in.

How to choose

Just heartbeat monitoring, minimal cost: Healthchecks.io free tier or self-hosted. Dead Man’s Snitch if you want simpler setup.

Heartbeat + uptime, no cron scheduling: Better Uptime.

Heartbeat + cron scheduling + uptime, one tool: Tickstem or Cronitor. Tickstem is cheaper for small teams; Cronitor is more mature for larger ones.

Already using Sentry: Add Sentry Crons first — it costs nothing extra and covers most cases.

The pattern that catches most developers is starting with Healthchecks.io for heartbeats, adding UptimeRobot for uptime, and eventually needing a cron scheduler — ending up with three separate tools. If you can see that combination in your future, starting with a bundle is worth the slightly higher entry cost.

AWS CloudFront Cache Policies: Complete Guide

A CloudFront cache policy controls two things: the cache key (which combination of URL, headers, cookies, and query strings makes a request unique) and the TTL (how long CloudFront keeps an object at the edge before re-checking the origin). Those two settings together determine your cache hit ratio.

The CloudFront console currently shows fifteen managed cache policies. Five are broadly useful on a typical self-managed distribution: CachingOptimized, CachingOptimizedForUncompressedObjects, CachingDisabled, UseOriginCacheControlHeaders, and UseOriginCacheControlHeaders-QueryStrings. One (Elemental-MediaPackage) is for AWS Elemental MediaPackage video origins. The remaining nine are Amplify-related — a standalone Amplify policy for Amplify origins plus eight Amplify-* policies that Amplify Hosting attaches to its own distributions automatically. You can also build a custom policy when none of the managed ones fit the shape of your traffic.

This is a follow-up to my earlier post on CloudFront’s three policy types, going one level deeper on just cache policies.

What a cache policy controls

A cache policy has three categories of settings: policy information (name and description, just metadata), TTL settings (Minimum, Maximum, Default), and cache key settings (which headers, cookies, and query strings to include). Everything that affects caching behavior lives in the latter two.

The cache key is the unique identifier CloudFront uses to look up an object at an edge location. If two viewer requests produce the same cache key, the second one is a cache hit. If they differ — say, one request includes a ?ref=twitter query string and the other does not, and your policy includes query strings in the cache key — they get treated as separate objects, even when the response body is identical. Cache key shape is the single biggest lever for hit ratio.

The TTL settings work alongside Cache-Control and Expires headers from your origin to determine how long a cached object stays valid at the edge. They behave subtly differently from each other; more on that next.

How Minimum, Maximum, and Default TTL actually work

The three TTL settings are not redundant. Each one applies in a different scenario, and getting them confused is one of the more common ways to either over-cache stale content or hammer your origin unnecessarily.

Default TTL is used when your origin sends no Cache-Control or Expires header at all. CloudFront falls back to this value, subject to the Minimum TTL floor — if Minimum TTL is greater than Default TTL, CloudFront caches for at least the Minimum TTL. The default for the AWS managed CachingOptimized policy is 86,400 seconds (24 hours).

Maximum TTL caps the TTL when your origin does send Cache-Control or Expires. If your origin says Cache-Control: max-age=5184000 (60 days) and Maximum TTL is 31,536,000 (365 days), CloudFront honors the origin’s 60 days. If your origin says max-age=63072000 (730 days), CloudFront caps it at 365 days. This setting only matters when you want to override an origin that’s claiming overly aggressive cache durations.

One nuance worth knowing: if your origin sends both max-age and s-maxage, CloudFront uses s-maxage for its own caching decisions and lets browsers use max-age. This is how you get different cache durations at the edge versus the browser without writing two policies.

Minimum TTL is the floor. CloudFront keeps the object for at least this long, no matter what. This one comes with a sharp edge: if Minimum TTL is greater than 0, CloudFront ignores Cache-Control: no-cache, no-store, and private directives from your origin. The object gets cached anyway, for at least the Minimum TTL duration. Both CachingOptimized and CachingOptimizedForUncompressedObjects have Minimum TTL of 1 second, and the standalone Amplify policy has Minimum TTL of 2 seconds — meaning you cannot reliably stop caching with origin headers alone if you are using them.

If you set all three TTLs to 0, caching is effectively disabled — which is exactly what CachingDisabled does.

Cache key settings: headers, cookies, query strings, and compression

Each of the three viewer-data sources (headers, cookies, query strings) can be configured independently. Cookies and query strings have four modes: include none, include all, include specifically named ones, or include all-except-named-ones. Headers are the exception — they support only “none” or a specific list. There is no “all headers” option, because that would be unbounded and would fragment the cache catastrophically.

When you include a header, cookie, or query string in the cache key, CloudFront also automatically forwards it to the origin on cache misses. The cache key and the origin request are coupled by default; the only way to forward something to the origin without affecting the cache key is to add it to a separate origin request policy. This is the most common confusion with cache policies and the reason CloudFront split them apart in the first place.

A subtle but important detail: cache key matching uses header, cookie, and query string names, but the matching is on the full name+value. Specifying session_id in the cache key means every distinct value of session_id produces a different cache key — so if every visitor has a unique session ID, every request is a cache miss. This is why “include all cookies” rarely makes sense for general traffic.

The compression settings (EnableAcceptEncodingGzip, EnableAcceptEncodingBrotli) tell CloudFront to normalize the Accept-Encoding header before adding it to the cache key. With both enabled, the cache key sees one of br,gzip, gzip, or br (depending on what the viewer supports), or no Accept-Encoding at all when the viewer supports neither — in that last case CloudFront sends Accept-Encoding: identity to the origin instead. Without normalization, every browser variation of Accept-Encoding: gzip, deflate, br, zstd would produce a distinct cache key. Enable this when your origin returns compressed responses or when CloudFront edge compression is on. Leave it off otherwise.

One gotcha: if you enable Gzip or Brotli in the cache policy, do not also include Accept-Encoding in an origin request policy attached to the same behavior. CloudFront handles that header itself when compression is enabled, and adding it to the origin request policy has no effect.

The AWS managed cache policies

This is the comparison table for the policies you’ll actually attach to a self-managed distribution by hand. The Amplify Hosting policies (Amplify-* and Amplify-*-V2) are intentionally excluded — see the Amplify section below for why.

Policy TTL (min/default/max) Cookies Query strings Headers in cache key Compression
CachingOptimized 1s / 24h / 365d None None None Gzip + Brotli
CachingOptimizedForUncompressedObjects 1s / 24h / 365d None None None Off
CachingDisabled 0 / 0 / 0 None None None Off
UseOriginCacheControlHeaders 0 / 0 / 365d All None Host, Origin, method overrides Gzip + Brotli
UseOriginCacheControlHeaders-QueryStrings 0 / 0 / 365d All All Host, Origin, method overrides Gzip + Brotli
Elemental-MediaPackage 0 / 24h / 365d None aws.manifestfilter, start, end, m Origin Gzip

CachingOptimized

ID: 658327ea-f89d-4fab-a63d-7e88639e58f6

The default choice for static content — S3 buckets, image assets, JS and CSS bundles, anything that does not change based on who is asking. The cache key is just the requested object plus the normalized Accept-Encoding. No headers, no cookies, no query strings. This produces the highest possible cache hit ratio for static content.

Watch the Minimum TTL of 1 second: even with Cache-Control: no-store on your origin, CloudFront holds the object for at least one second. That is usually fine, but if you are using this policy on something where origin-side cache busting needs to take effect immediately, swap to UseOriginCacheControlHeaders or CachingDisabled.

CachingOptimizedForUncompressedObjects

ID: b2884449-e4de-46a7-ac36-70bc7f1ddd6d

Identical to CachingOptimized except compression is off. Use this when your origin does not return Gzip or Brotli (raw binary files, video segments, pre-compressed media formats like MP4 or WebP) and you are not using CloudFront edge compression. With compression off, the Accept-Encoding header is excluded from the cache key entirely, which keeps things simple.

If you cannot decide between this and CachingOptimized, default to CachingOptimized. The compression setting only causes problems when your origin produces objects that do not benefit from compression — and even then, it is a minor inefficiency rather than a correctness bug.

CachingDisabled

ID: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad

All TTLs at 0. Nothing in the cache key. Every request goes straight through to the origin. Use this for API behaviors, dynamic GET endpoints that should never be cached, real-time data, WebSockets, and anywhere caching would be incorrect rather than just suboptimal. (POST, PUT, and DELETE aren’t cached by CloudFront in the first place — only GET, HEAD, and optionally OPTIONS are — so attaching CachingDisabled to those methods is more about being explicit than functionally necessary.)

UseOriginCacheControlHeaders, UseOriginCacheControlHeaders-QueryStrings, and Elemental-MediaPackage also have Minimum TTL of 0, so they too will respect Cache-Control: no-store from your origin. The difference is that CachingDisabled never caches anything regardless of origin headers, while the others cache when the origin tells them to.

UseOriginCacheControlHeaders

ID: 83da9c7e-98b4-4e11-a168-04f0df8e2c65

Defers caching duration to your origin’s Cache-Control and Expires headers. If the origin says max-age=600, CloudFront caches for 10 minutes. If the origin says no-store, CloudFront does not cache at all (because Minimum TTL is 0).

This is the right choice for CMS-backed sites, mixed static-and-dynamic apps, and anywhere your application code already knows what should be cached and for how long. WordPress, Drupal, server-rendered Next.js, and most traditional web apps fit here.

The cache key includes all cookies plus Host, Origin, and three method-override headers (X-HTTP-Method-Override, X-HTTP-Method, X-Method-Override). The cookie inclusion is significant: if your application sets per-user session cookies on every response, you will fragment the cache per user. Either avoid setting cookies on cacheable responses, or stick with CachingOptimized for paths that should be shared across users.

UseOriginCacheControlHeaders-QueryStrings

ID: 4cc15a8a-d715-48a4-82b8-cc0b614638fe

Same as UseOriginCacheControlHeaders but with all query strings included in the cache key. Use this when your origin returns different responses based on query string values — search results, filtered listings, paginated content — and you want CloudFront to cache each variant separately.

The trade-off is cache fragmentation. URLs that include UTM parameters, click IDs, or other tracking junk get cached independently, which both lowers your hit ratio and bloats CloudFront’s storage of your content. If you can strip those server-side or with a CloudFront Function before this policy applies, do.

Amplify (and the eight related Amplify policies)

ID: 2e54312d-136d-493c-8eb9-b001f22f67d2

Two distinct things share the Amplify name. The standalone Amplify policy is documented as a regular CloudFront managed policy designed for use with an Amplify web app origin — Min TTL 2 seconds, Max TTL 600 seconds (10 minutes), Default TTL 2 seconds, with Authorization, CloudFront-Viewer-Country, and Host in the cache key plus all cookies and all query strings. AWS doesn’t warn against using it; it’s just narrowly tuned for Amplify-shaped workloads. The 2-second Minimum TTL means even no-store responses get cached briefly, which is rarely what you want outside of that specific architecture.

The eight Amplify-* policies are something else entirely: Amplify-Default, Amplify-DefaultNoCookies, Amplify-ImageOptimization, Amplify-StaticContent, plus a -V2 variant of each. These are managed by Amplify Hosting itself — Amplify attaches them to the distributions it provisions and resets them on every deploy. AWS explicitly says “we don’t recommend that you use these policies for your distributions.” If you need similar cache key shapes for a non-Amplify app, copy the settings into a custom policy.

The V2 variants appear to be tied to the August 2024 Amplify Hosting caching overhaul, which raised default static asset cache duration from 2 seconds to 1 year and Maximum TTL from 10 minutes to 1 year. AWS hasn’t documented the V2 policies in the public CloudFront developer guide as of this writing — they’re visible in the console but the documentation only covers the four originals.

Elemental-MediaPackage

ID: 08627262-05a9-4f76-9ded-b50ca2e3a84f

For AWS Elemental MediaPackage origins specifically — HLS and DASH video streaming. The cache key includes the four query string parameters that MediaPackage actually uses for manifest filtering and time-shifted playback (aws.manifestfilter, start, end, m) plus the Origin header for CORS. Other query strings are excluded, which keeps the cache key tight even when player libraries append cache-busting noise.

If you are not using MediaPackage, do not use this policy. If you are, use it — it is tuned for the specific request shape MediaPackage produces.

How to choose a cache policy

Match your origin and content type to the policy that is already tuned for it.

Use case Recommended policy
S3 static site, image bucket, asset CDN CachingOptimized
Video files (MP4, WebP), pre-compressed binaries CachingOptimizedForUncompressedObjects
API endpoints, dynamic GET responses, real-time data CachingDisabled
WordPress, Drupal, server-rendered apps with origin Cache-Control UseOriginCacheControlHeaders
Same as above, but query strings affect the response UseOriginCacheControlHeaders-QueryStrings
AWS Elemental MediaPackage HLS/DASH origin Elemental-MediaPackage
Custom requirements that do not match any of the above Custom cache policy

For a single distribution serving multiple content types, you typically attach different policies to different cache behaviors. A common setup: CachingOptimized on the default behavior (static assets), CachingDisabled on /api/*, UseOriginCacheControlHeaders on /blog/* if blog pages set their own Cache-Control.

When to create a custom cache policy

Reach for a custom policy when you need to include something specific in the cache key that the managed policies do not cover. The most common reasons are device-type segmentation using CloudFront-Is-Mobile-Viewer (serving different markup to phones versus desktops), country-based variation using CloudFront-Viewer-Country (geo-targeted content where edge handling beats origin-side detection), language negotiation via Accept-Language, and tier-based content where a coarse-grained auth bucket determines which response to serve.

Here is a Terraform example for a custom policy that varies on viewer country and a coarse auth-tier cookie:

resource "aws_cloudfront_cache_policy" "country_and_tier" {
  name        = "country-and-tier"
  default_ttl = 3600       # 1 hour when origin sends no Cache-Control
  max_ttl     = 86400      # cap origin Cache-Control at 24 hours
  min_ttl     = 0          # respect origin no-store

  parameters_in_cache_key_and_forwarded_to_origin {
    # normalize Accept-Encoding so cache key collapses across browsers
    enable_accept_encoding_gzip   = true
    enable_accept_encoding_brotli = true

    headers_config {
      header_behavior = "whitelist"
      headers {
        items = ["CloudFront-Viewer-Country"]  # geo-target at the edge
      }
    }

    cookies_config {
      cookie_behavior = "whitelist"
      cookies {
        # coarse bucket like anonymous/free/premium — NOT a per-user session ID
        items = ["auth_tier"]
      }
    }

    query_strings_config {
      query_string_behavior = "none"
    }
  }
}

The cookie name matters here. auth_tier with values like anonymous, free, and premium produces three cache variants per country, which is reasonable. A per-user session token in the same slot would produce one cache variant per user, which is the cache-buster pattern the article warns against earlier.

A few things to know when going custom. CloudFront-generated headers like CloudFront-Viewer-Country are not sent to the origin by default — you need to either include them in the cache key (which automatically forwards them to the origin) or add them to an origin request policy if you want them at the origin without varying the cache. The cache compression toggle should match what your origin returns; turning it on when your origin does not compress can fragment the cache without benefit. And keep Minimum TTL at 0 unless you specifically want to override origin no-store directives, because the surprise-caching behavior burns a lot of debugging hours when you forget about it.

Common pitfalls

Cache hit ratio tanks after a policy change. Almost always means the cache key got too specific. Check whether the new policy includes cookies or query strings the previous one did not, and re-check whether your origin sets per-user cookies on responses that should be shared across users.

Origin keeps getting hit despite a long Default TTL. Default TTL only applies when the origin sends no Cache-Control or Expires header. If your origin sets Cache-Control: max-age=60, CloudFront uses 60 seconds, not the policy’s Default TTL. Either change the origin or use Maximum TTL to cap origin-controlled durations.

no-cache from origin is being ignored. Several managed policies (CachingOptimized, CachingOptimizedForUncompressedObjects, and Amplify) have a Minimum TTL greater than 0, which overrides origin no-cache directives. If you need origin-side cache busting to take effect immediately, switch to CachingDisabled or build a custom policy with Minimum TTL = 0.

Forwarding CloudFront-Viewer-Country to origin does not work. For CloudFront-generated headers like CloudFront-Viewer-Country, make sure the header is explicitly enabled via a cache policy or an origin request policy — they aren’t sent to your origin by default. If it’s in the cache key, it’s also forwarded to the origin automatically. If you only need it at the origin and don’t want to vary the cache, put it in an origin request policy instead. (Note: a few CloudFront-* headers, including CloudFront-Viewer-Address, CloudFront-Viewer-ASN, and the TLS-related ones, can only be added via an origin request policy and not in a cache policy.)

Authorization header is not reaching the origin. CloudFront removes it by default. To forward it, either include Authorization in the cache key with a cache policy, or use an origin request policy that forwards all viewer headers (the managed Managed-AllViewer policy does this). You cannot forward only Authorization via an origin request policy. Be careful: putting Authorization in the cache key creates per-token cache variants and is usually inappropriate for broadly shared cacheable content.

What’s next

If you came here looking for “which cache policy should I use,” the short answer is CachingOptimized for static content, CachingDisabled for APIs, UseOriginCacheControlHeaders for content where your origin can express its own caching intent. Everything else is tuning at the margins.

The next post in this series goes deep on origin request policies — where the separation between cache key and origin forwarding really earns its keep — followed by response headers policies, which can replace a lot of security middleware with a single CloudFront config.

Why CJK Support in Rust Is Hard

Most Rust developers don’t think about CJK until they need it. Then they discover that embedding Japanese text in a PDF, building a search index over Chinese content, or normalizing Korean input involves a stack of interlocking problems that Latin-script tooling simply never had to solve.

This post breaks down why CJK is genuinely hard — not just “different” — and where the Rust ecosystem still has gaps.

The Scale Problem

The first thing that surprises developers: a full CJK font file is enormous.

A Latin font like Inter Regular is around 300 KB. A full Japanese font — say, Noto Sans CJK JP — is over 15 MB. That’s because Unicode’s CJK Unified Ideographs block alone defines over 92,000 characters, and a production font needs to cover most of them.

For most use cases you don’t need all 92,000 glyphs. If you’re generating a PDF invoice with a customer name and address, you might use 50 distinct CJK characters. But a naive approach embeds the entire font, making a simple document balloon to 15 MB.

The solution is font subsetting: extract only the glyphs actually used, rebuild a minimal font binary, and embed that. It sounds straightforward. It isn’t.

The Three Hard Problems

1. Font Subsetting for CJK

Subsetting a Latin font is well-understood. Subsetting a CJK font involves:

Glyph ID remapping. A font maps Unicode code points to internal Glyph IDs (GIDs). After subsetting, the GID space is compacted — the 50 glyphs you kept now have new GIDs from 0 to 49. Every reference to the old GIDs in the font binary and in your document needs to be updated.

CMap table reconstruction. The font’s cmap table maps Unicode → GID. After subsetting, this table must be rebuilt to reflect the new GID assignments. Get this wrong and the font renders garbage or fails to load entirely.

Advance width recalculation. Fonts store per-glyph advance widths (how far the cursor moves after each character). After GID remapping, the width table must be reindexed. In PDF specifically, the /Widths array in the CIDFont object must match the new GIDs exactly — a mismatch causes text spacing to break in subtle, hard-to-debug ways.

Type0/CIDFont object graph. PDF represents CJK fonts as a two-level structure: a Type0 (composite) font wrapping a CIDFont. The CIDFont references the embedded font stream and the ToUnicode CMap. Building this object graph correctly requires understanding the PDF spec at a level most developers would rather avoid.

In pure Rust, the allsorts crate handles TTF subsetting. It works well for TrueType fonts. OpenType CFF fonts (.otf files with PostScript outlines) are more complex and allsorts coverage is incomplete — this is a known gap in the Rust ecosystem.

2. ToUnicode CMap Generation

PDF separates rendering (which glyph to draw) from semantics (what Unicode character that glyph represents). Rendering uses GIDs. Semantics are stored in a separate stream called the ToUnicode CMap.

Without a ToUnicode CMap:

  • Copy-pasting text from a PDF produces garbage characters or empty strings
  • Search within the PDF doesn’t find CJK text
  • Screen readers can’t read the document

The CMap is a PostScript-like stream that maps GID ranges to Unicode code points. For CJK fonts with thousands of glyphs, generating this stream correctly — with proper range compression for consecutive code points — requires care. A naive one-entry-per-glyph approach technically works but produces unnecessarily large streams.

3. Normalization and Variant Characters

CJK text has an encoding problem that Latin scripts largely don’t: the same logical character can have multiple valid representations.

Unicode normalization forms (NFC, NFD, NFKC, NFKD) affect how composed characters are stored. Japanese text in particular mixes hiragana, katakana, kanji, and Latin characters, each with their own normalization quirks. Fullwidth ASCII (A, B, C) and halfwidth katakana (ア, イ, ウ) are canonically equivalent to their standard forms under NFKC but not NFC.

CJK Compatibility Ideographs (U+F900–U+FAFF) are compatibility mappings for characters that appear in legacy encodings. U+FA30 (㌍) is canonically equivalent to U+30AD U+30ED (キロ). Depending on whether you normalize before indexing, the same string might or might not match a query.

Variant selectors add another layer. CJK Unified Ideographs sometimes have multiple visual forms (simplified vs. traditional Chinese, Japanese vs. Korean glyph shapes). Unicode encodes this with Variation Selectors — invisible code points that follow a base character to select a specific glyph. 葛 followed by VS17 (U+E0100) selects a specific variant used in place names. A text search that isn’t VS-aware will fail to match these strings.

For fuzzy matching over CJK content, you need to decide which of these equivalences to collapse before indexing. The right answer depends on the use case: a legal document system probably wants exact glyph matching; a general search index probably wants NFKC normalization.

The Legacy Encoding Problem

Modern CJK text is Unicode, but a significant amount of real-world content is still encoded in legacy formats:

Encoding Used for
Shift-JIS Legacy Japanese text, older Windows software
EUC-JP Unix-era Japanese
GB2312 / GBK / GB18030 Simplified Chinese
Big5 Traditional Chinese (Taiwan/Hong Kong)
EUC-KR / CP949 Korean

Converting these to Unicode isn’t just a lookup table — legacy CJK encodings have overlapping code spaces, vendor extensions, and edge cases that differ between Windows, macOS, and Linux implementations.

The encoding_rs crate (originally written for Firefox) is the authoritative pure Rust implementation of the WHATWG Encoding Standard and handles most of these correctly. This is one area where the Rust ecosystem is actually in good shape.

Why Everything Still Leans on C

The elephant in the room: most production CJK text processing still depends on C or C++ libraries.

HarfBuzz — text shaping (converting Unicode to positioned glyphs) — is C++. For CJK, shaping is relatively simple compared to Arabic or Indic scripts (no complex ligatures or bidirectional reordering), but HarfBuzz is still the de facto standard.

FreeType — font rasterization — is C. If you’re rendering CJK text to a bitmap, you’re almost certainly using FreeType bindings.

ICU (International Components for Unicode) — normalization, collation, locale-aware string comparison — is C++. The icu4x project is a ground-up Rust rewrite led by the Unicode Consortium, and it’s making solid progress, but it’s not yet a drop-in replacement for all ICU use cases.

The consequence for Rust developers: if you need CJK support and reach for crates that wrap these C libraries, you give up WASM compatibility, you complicate cross-compilation, and you add a build-time dependency on the system libraries or vendored C sources.

The Current State of Pure Rust CJK

Here’s an honest assessment of the pure Rust ecosystem for CJK work:

Task Pure Rust option Status
Legacy encoding conversion encoding_rs Solid
Unicode normalization unicode-normalization Solid
TTF font subsetting allsorts Good for TrueType; OTF/CFF partial
PDF CJK text embedding harumi New, niche
Text shaping None Gap — no pure Rust HarfBuzz equivalent yet
Font rasterization fontdue Works but limited CJK testing
Full Unicode collation icu4x In progress

The gaps are real. Text shaping in particular is a hard open problem for pure Rust — for simple CJK rendering you can get away without a full shaper, but for mixed CJK/Latin text with proper kerning and ligatures, you eventually need something HarfBuzz-level.

What This Means in Practice

If you’re building something that needs to handle CJK text in Rust:

  • For encoding conversion, use encoding_rs. Don’t roll your own.
  • For normalization, use unicode-normalization and decide up front which form you want. For search, NFKC is usually the right default.
  • For PDF with CJK, the pure Rust path exists but requires understanding the subsetting pipeline. Wrapping pdfium or a C-based library is currently the easier path if WASM compatibility isn’t a requirement.
  • For fuzzy search over CJK, normalization before indexing matters more than the search algorithm itself.
  • For text rendering, if you can accept a C dependency, HarfBuzz + FreeType is the proven path. Pure Rust rendering is possible for simple cases.

Closing Thought

CJK support isn’t a single feature — it’s a stack of problems that compound. The good news is that the Rust ecosystem is making real progress on each layer. The bad news is that each layer requires understanding the layer below it, which is why CJK support tends to be either “works perfectly” or “completely broken” with little middle ground.

If you’re working on any of these problems — subsetting, normalization, collation, shaping — I’d love to compare notes.

I’ve run into most of these problems while building harumi, a pure Rust PDF library with CJK font subsetting. The gaps in the table above are the ones I’ve personally hit.

The AI visibility checklist I used on my own agency site

A few weeks ago I asked an answer engine to recommend an AI-visibility consultant. The first five results were a wall of slop. Three were SEO agencies that had rewritten their homepage with the word “GEO” stapled to it. One was a Medium post by someone who clearly had not shipped anything. One was a course. None of the sites being recommended actually had a single machine-readable artifact that explained what the business did, who ran it, or what it cost.

That was the moment the line landed for me: AI systems cannot recommend what they cannot understand.

If I want to sell AI visibility work, I have to be visible. Not on a marketing-deck level. On a “the model can answer questions about me without guessing” level. So before I wrote a single outbound message about the service, I ran the checklist on my own site. This post is that checklist, with what I actually changed on dee.agency.

SEO is the floor. Entity clarity is the next layer.

I want to be specific about what changed and what did not, because there is a lot of bad faith in this space right now.

SEO is not dead. It is the floor. If Google cannot crawl your site, no answer engine is citing you either, because most of them lean on the same crawl graph. SEO makes your pages findable. What changed is the finish line. Findable is no longer enough. Now the model has to be able to pull a clean, confident answer out of your pages without inventing the parts you forgot to write down.

I wrote a paper this year on what I call the SEO-to-GEO gap (DOI 10.2139/ssrn.6476021). The short version: the gap is not algorithmic, it is descriptive. Sites that rank fine still get hallucinated about, because ranking signals reward link patterns and content depth, while answer engines reward entity clarity and machine-readable context. Two different finish lines.

The checklist below is the boring part. No magic prompts. Seven steps. I ran it on dee.agency over a weekend.

The 7-step AI visibility checklist

1. Define your entity in one sentence

The first thing a model needs is a sentence it can quote. If your homepage hero is a vibe (“we build the future of work”), the model will paraphrase you into something generic, or worse, into something a competitor said.

What I did on dee.agency: one sentence at the top. “Dee Kargaev. Indie builder who designs and ships AI products solo, then sells the same playbook as packaged services.” That same sentence is in the description field of the homepage schema, and it opens /llms.txt. Three surfaces, one sentence, zero ambiguity.

Watch for: the version on your About page being different from the version in your schema. If those drift, models pick the worse one.

2. Make services and pricing unambiguous

Most agency sites do not list prices. They list “let’s talk.” Fine for an inbound funnel staffed by humans. Bad for an answer engine, which now has to either guess or refuse.

I put the full ladder on dee.agency/pricing: $500 Audit + Spec, $3K AI Visibility / GEO Fix, retainer ranges, what each includes, what it excludes, lead times. Then I marked it up as Offer schema so the prices are not just visible to humans, they are typed.

The day after I pushed it, ChatGPT could answer “what does dee.agency charge for an AI visibility audit?” with the right number. Before, it would politely refuse. That is the whole game in one example.

3. Add machine-readable context with /llms.txt

/llms.txt is a plain markdown file at the root of your domain. It is not a ranking hack. It is not magic. It is a README for the LLM crawlers, structured the way humans structure READMEs: who you are, what you do, what is on each important URL, what to read first.

dee.agency/llms.txt has six sections: entity sentence, services, pricing, proof, founder bio, “if you only have time to read three URLs, read these.” That last section matters more than people think. Models choose what to ingest under token budgets. Telling them what to prioritize is not gaming the system, it is good documentation.

Watch for: putting marketing copy in /llms.txt. The file should read like internal onboarding notes, not a landing page.

4. Add structured data and sameAs links

Schema.org gives models typed handles to grab. Without it, every fact on your page is a string the model has to interpret. With it, your name is a Person.name, your offers are Offer objects, your articles are Article with an author.

The piece most people miss is sameAs. It is the array of canonical URLs for the same entity across the web. On dee.agency, my Person schema has sameAs pointing at my GitHub, my Google Scholar profile (author ID gEVwv4QAAAAJ), my LinkedIn, the SSRN paper page, and the awesome-geo repo (github.com/deeflect/awesome-geo, 168 platforms, 142 of them AI-discoverable). That array is how models stitch “Dee on this site” to “Dee in this paper” to “Dee on GitHub” into one entity instead of three coincidences.

5. Build answer-first pages for the commercial questions

For every commercial question your buyer would type into an answer engine, you should have exactly one page that answers it cleanly.

For dee.agency, the four pages are: /geo (what GEO is and how I do it), /audit (what the audit covers and what you get), /pricing (the ladder), and /about (who runs this and why). Each one opens with a direct answer in the first 80 words, not a buildup. Each one has its own schema. Each one is linked from /llms.txt.

Watch for: writing these pages like blog posts. Answer engines extract from the top. Bury the answer past the fold and you will be paraphrased.

6. Check crawler access and robots rules

There is no point being clear if you are blocking the parsers reading you. Half the sites I audit have a robots.txt that silently blocks GPTBot, ClaudeBot, or PerplexityBot, usually because someone copy-pasted a rule from a privacy-focused template in 2024 and forgot.

I run curl against the relevant user agents and check the actual response, not the developer console. dee.agency/robots.txt allows GPTBot, ClaudeBot, PerplexityBot, Google-Extended, and CCBot, and disallows the predatory scrapers that do not respect any of this anyway (a different fight). The full list lives in the awesome-geo repo so I do not have to remember it.

Watch for: Cloudflare’s “block AI bots” toggle, which is on by default in some plans. It will quietly undo everything else on this list.

7. Run repeatable probes before and after

This is the step almost nobody does, and it is the only one that tells you if the work worked.

A probe is a fixed prompt run against a fixed set of engines on a fixed cadence. For dee.agency I track 12 prompts (“who is Dee Kargaev”, “best AI visibility consultant for early-stage SaaS”, “what is the SEO-to-GEO gap”, etc.) across ChatGPT, Claude, Perplexity, and Gemini, weekly. I log which engines cite the site, which paraphrase without citing, and which hallucinate.

Without probes, you are guessing. With probes, you have a before-and-after dataset that survives the next model update. The real bar is run-grade GEO: still visible a month after launch, not demo-grade visibility that decays the moment the index refreshes.

What this is not

This is not a course. It is not a “5 secrets” thread. It is not magic prompts. It is seven boring infrastructure changes, three of which are pure devops, and four of which are writing clearly about your own business.

It is also not credible coming from anyone who has not done it on their own surface. Selling AI visibility while your own site has no /llms.txt, no schema, no pricing page, and a robots.txt that blocks the parsers, is the half-builder pattern in its purest form. You are selling the diagram of the thing, not the thing. That is the tell I look for when I evaluate other people in this space, and I held myself to it before writing a word of outbound.

If you want this run on your site

I sell this. dee.agency/audit is a $500 Audit + Spec, credited 100% toward implementation if you book within 30 days. dee.agency/geo is the $3K AI Visibility / GEO Fix, one week, the full checklist run with probes before and after and a writeup at the end.

If you would rather run it yourself, the awesome-geo repo has the platform list, the probe templates, and the schema examples. Show me what you ship.

Creando un Tetris con JavaScript V: el bucle de juego

En este momento, sabemos que, utilizando el elemento Canvas de HTML5, podemos dibujar el tablero, así como la pieza que va cayendo. Pero nada se mueve, la pieza no cae, y desde luego, no podemos moverla.

Las piezas disponibles, para seleccionar la que está cayendo en este momento, son las siguientes:

const PIECES = [
            new PieceSquare(),
            new PieceBar(),
            new PieceL(),
            new PieceInverseL(),
            new PieceS(),
            new PieceInverseS() ];

Necesitamos un bucle de juego, y para ello sería interesante disponer de, o bien varias variables y funciones, o mejor todavía, un objeto. Se trataría de una clase que solo tendría una posible instancia. En otro lenguaje de programación, utilizaríamos el patrón Singleton. Pero en JavaScript, podemos crear un objeto único utilizando la notación JSON (JavaScript Simplified Object Notation), que podemos utilizar directamente en nuestro código fuente.

const game = {
    score: 0,
    piece: null,
    board: null,
    canvas: null,
    keysPressed: [],


    pickRandomPiece: () => {
        return PIECES[ Math.ceil( Math.random() * PIECES.length ) - 1 ];
    },


    chooseNewPiece: () => {
        game.piece = game.pickRandomPiece();
        game.canvas.piece = game.piece;
        game.piece.reset( game.board.cols );
    },

    // más cosas...
}

Nuestro objecto, que va a llevar el estado del juego, es game. Dentro de este objeto, construiremos el tablero del juego, la pieza actual, el canvas donde vamos a pintar el juego, etc.

const game = {
    // más cosas...    
    stopGame: () => {
        const dvStart = document.getElementById( "dvStart" );
        const dvGame = document.getElementById( "dvGame" );

        dvStart.style.display = "block";
        dvGame.disabled = true;
    },

    // más cosas...
}

En nuestro (parco) HTML, tendremos un botón (Button), que servirá para comenzar el juego. Habrá un DIV que contendrá este botón, y un segundo DIV que contendrá el HTML Canvas. Según ocultemos o visualicemos (style=”display: none”) o (style=”display: block”), cada uno de estos DIVs, podremos comenzar el juego o jugar.

const game = {
    startGame: () => {
        const dvStart = document.getElementById( "dvStart" );
        const dvGame = document.getElementById( "dvGame" );
        const cvPaint = document.getElementById( "cvPaint" );

        // Set
        dvStart.style.display = "none";
        dvGame.disabled = false;

        game.score = 0;
        game.board = new Board( 26, 12 );
        game.canvas = new Canvas( cvPaint, game.board );
        game.chooseNewPiece();

        // Listeners
        window.requestAnimationFrame( () => game.showFrame() );
        document.onkeydown = (e) => {
            if ( e.isComposing
              || e.key === "Dead" )
            {
                return;
            }

            game.keysPressed.push( e );
        };

        return false;
    },

    // más cosas...
}

Además, debemos poder “animar” el juego. Tiene que haber una forma de conseguir que, una vez se pinte un frame, establezcamos algún mecanismo para “encargar” el pintado del siguiente.

Podemos establecer el pintado del primer frame con window.requestAnimationFrame(), al que se le pasa una lambda con el código a ejecutar. De acuerdo, pero… ¿cómo hacemos para ejecutar la siguiente? Pues volvemos a asignar window.requestAnimationFrame().

En cuanto a las pulsaciones de teclas, se utiliza el método document.onkeydown, de manera que en este método se filtran las teclas que no producen ninguna pulsación, y se guarda en la lista keysPressed.

const game = {
    // más cosas...

    chkKeyboard: () => {
        for(let key of game.keysPressed) {
            if ( key.code == "ArrowLeft" ) {
                game.piece.col -= 1;
                if ( game.chkPieceClash() ) {
                    game.piece.col += 1;
                }
            }
            else
            if ( key.code == "ArrowRight" ) {
                game.piece.col += 1;
                if ( game.chkPieceClash() ) {
                    game.piece.col -= 1;
                }
            }
            else
            if ( key.code == "ArrowUp" ) {
                game.piece.rotate();
                if ( game.chkPieceClash() ) {
                    game.piece.unrotate();
                }
            }
            else
            if ( key.code == "ArrowDown" ) {
                game.piece.row += 1;
                if ( game.chkPieceClash() ) {
                    game.piece.row -= 1;
                }
            }
        }

        game.keysPressed = [];
    },

    // más cosas...
}

En cuanto a chkKeyboard(), se detectan las teclas de los cursores, y en caso de que una de ellas sea la flecha hacia arriba, la pieza se rota. En el resto de los casos, la posición de la pieza se cambia… a no ser que se compruebe que coincida con el tablero.

const game = {
    // más cosas...

    chkPieceClash: () => {
        const PIECE = game.piece;
        const BOARD = game.board;

        let toret = 1;

        if ( game.chkInbounds() ) {
            toret = 0;

            for(let i = 0; i < PIECE.height; ++i)
            {
                if ( ( PIECE.row + i ) >= BOARD.rows ) {
                    break;
                }

                for(let j = 0; j < PIECE.width; ++j)
                {
                    if ( ( PIECE.col + j ) >= BOARD.cols ) {
                        break;
                    }

                    toret |= PIECE.shape[i][j]
                           & BOARD.cell( PIECE.row + i, PIECE.col + j );

                    if ( Boolean( toret ) ) {
                        break;
                    }
                }
            }
        }

        return Boolean( toret );
    },

    // más cosas....
}

Para comprobar si la pieza coincide con alguna pieza almacenada en el tablero, será necesario comprobar si alguna de las celdas de la pieza y la posición correspondiente en el tablero contienen ambas un 1. En ese caso, la pieza no puede moverse a esa posición, con lo que será necesario deshacer el movimiento. Si es una rotación, será necesario cancelar la rotación (método Piece.unrotate()).

    chkFilledRows: () => {
        const PIECE = game.piece;
        const BOARD = game.board;
        let toret = [];

        for(let i = 0; i < PIECE.height; ++i) {
            const NUM_ROW = i + PIECE.row;

            if ( BOARD.getRow( NUM_ROW ).every( x => Boolean(x) ) ) {
                toret.push( NUM_ROW );
            }
        }

        return toret;
    },

    portPieceToBoard: () => {
        const PIECE = game.piece;
        const BOARD = game.board;
        const MAX_HEIGHT = Math.min( BOARD.rows - 1, PIECE.row + PIECE.height );
        const MAX_WIDTH = Math.min( BOARD.cols - 1, PIECE.col + PIECE.width );

        for(let i = 0; i < PIECE.height; ++i) {
            for(let j = 0; j < PIECE.width; ++j) {
                const BOARD_POS_ROW = PIECE.row + i;
                const BOARD_POS_COL = PIECE.col + j;

                if ( BOARD_POS_ROW >= BOARD.rows
                  || BOARD_POS_COL >= BOARD.cols )
                {
                    break;
                }

                if ( Boolean( PIECE.shape[ i ][ j ] ) ) {
                    BOARD.setCell( BOARD_POS_ROW, BOARD_POS_COL );
                }
            }
        }

        return;
    }

    // más cosas...
}

El método chkFilledRows() comprueba si todas las celdas de alguna de las filas del tablero están llenas. Con el método every() para Array, podemos comprobar si se cumple una condición. De nuevo convertimos los 1 y 0 de la fila a booleano, y comprobamos entonces si todas dan true. En este último caso, pasamos el índice de la fila a un Array que vamos a devolver. Después llamaremos a Board.removeRows() para eliminarlas.

El método portPieceToBoard() comprueba todas las celdas de una pieza, y que en caso de que contengan un 1, portar ese 1 a la misma posición, pero en el tablero.

Ya solo nos falta comprobar si hay colisión entre la pieza que desciende y el contenido del tablero. Nos aprovechamos de que tanto la pieza como el tablero son Array‘s, y que en caso de que la posición esté ocupada, en cualquiera de los dos casos, va a contener un 1. Así, un and, nos permite generar un booleano que nos diga si hay coincidencia entre alguna de las casillas.

const game =
    // más cosas...

    chkPieceClash: () => {
        const PIECE = game.piece;
        const BOARD = game.board;

        let toret = 1;

        if ( game.chkInbounds() ) {
            toret = 0;

            for(let i = 0; i < PIECE.height; ++i)
            {
                if ( ( PIECE.row + i ) >= BOARD.rows ) {
                    break;
                }

                for(let j = 0; j < PIECE.width; ++j)
                {
                    if ( ( PIECE.col + j ) >= BOARD.cols ) {
                        break;
                    }

                    toret |= PIECE.shape[i][j]
                           & BOARD.cell( PIECE.row + i, PIECE.col + j );

                    if ( Boolean( toret ) ) {
                        break;
                    }
                }
            }
        }

        return Boolean( toret );
    },

    // más cosas...
}

Y finalmente, el núcleo del juego. En él, se comprueban las teclas pulsadas, se cambia la posición de la pieza si es necesario, se comprueba si coincide con el fondo del tablero o no, o si la pieza ya no puede moverse desde la primera fila (en ese caso, el juego termina). En cualquier caso, al final ya con la posición de la pieza definitiva, es cuando se pinta el frame.

const game = {
    // más cosas...

    showFrame: () => {
        const pScore = document.getElementById( "pScore" );
        let millis = performance.now() - game.startTime;

        if ( millis >= 10 ) {
            game.pushPieceDownTime += 1;

            if ( game.pushPieceDownTime >= 40 ) {
                game.pushPieceDownTime = 0;
                game.piece.row += 1;
                const isAtBottom = ( game.piece.row + game.piece.height ) >= game.board.rows;

                if ( game.chkPieceClash()
                  || isAtBottom )
                {
                    if ( !isAtBottom ) {
                        game.piece.row -= 1;
                    }

                    if ( game.piece.row != 0 ) {
                        let newScore = 0;

                        game.portPieceToBoard();
                        newScore = game.calculateScore();
                        if ( newScore > 0 ) {
                            game.score += newScore;
                            pScore.innerText = "" + game.score;
                        }

                        let filledRows = game.chkFilledRows();
                        game.board.removeRows( filledRows );
                        game.board.insertEmptyRows( filledRows.length );
                    } else {
                        game.stopGame();
                    }

                    game.chooseNewPiece();
                }
            }

            game.chkKeyboard();            
            game.drawFrame();
            game.startTime = performance.now();
        }

        window.requestAnimationFrame( () => game.showFrame() );
    },

    // más cosas...
}

Primero se hacen varias comprobaciones en cuanto a que haya pasado suficiente tiempo. De otra forma, se pintarían demasiados frames, y dado que pintar un solo frame lleva un tiempo, se establece un tiempo de 10 ms. mínimo entre frames. También se establece el contador pushPieceDownTime, de manera que el tiempo entre el movimiento de la pieza que cae, y el siguiente, sea de al menos 400 ms., es decir, casi medio segundo. Finalmente, se comprueba el teclado, se pinta el frame, y se vuelve a llamar a window.requestAnimationFrame() para el siguiente frame.

En cuanto a la pieza, en caso de que al caer una fila más en el tablero, esta coincida con los restos, entonces se porta esa pieza al tablero (pasa a formar parte de los restos), se comprueba si hay filas completamente rellenas, y en ese caso se eliminan, y se insertan tantas filas en blanco como sea necesario.

Y básicamente ya está. Puedes ver el código, y echar unas partidas en el repo de GitHub para Insertrix.