{"id":158,"date":"2026-08-07T09:00:00","date_gmt":"2026-08-07T09:00:00","guid":{"rendered":"https:\/\/vectormatch.dev\/blog\/?p=158"},"modified":"2026-08-07T11:05:24","modified_gmt":"2026-08-07T11:05:24","slug":"rate-limiter-only-broke-in-production","status":"publish","type":"post","link":"https:\/\/vectormatch.dev\/blog\/rate-limiter-only-broke-in-production\/","title":{"rendered":"The Rate Limiter That Only Broke in Production"},"content":{"rendered":"<p class=\"vm-lead\">Six parallel workers, six independent counters, one provider cap \u2014 and a test suite that never caught it because every test ran sequentially.<\/p>\n<div class=\"vm-tldr\">\n<p class=\"vm-tldr-label\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:.9rem;height:.9rem;\"><path d=\"M13 2 3 14h9l-1 8 10-12h-9l1-8z\"\/><\/svg>The short answer<\/p>\n<p>I deployed a Bottleneck.js rate limiter capped at 2 req\/s per ATS platform. It passed every test. In production, six parallel Inngest workers each got their own in-process limiter, so the aggregate hit 12 req\/s \u2014 six times the cap. The provider returned 429s within minutes. The fix: move the limiter state into Redis so all workers share a single global counter. The bug was not in the code but in my mental model of what &#8216;rate limited&#8217; means across processes.<\/p>\n<\/div>\n<h2>The Bug: 2 req\/s in Tests, 12 req\/s in Production<\/h2>\n<p>The rate limiter was simple and correct \u2014 on paper. A <a href=\"https:\/\/github.com\/SGrondin\/bottleneck\" target=\"_blank\" rel=\"noopener\">Bottleneck.js<\/a> instance per ATS platform (Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee), each configured with <code>minTime: 500<\/code> milliseconds and <code>maxConcurrent: 1<\/code>. That enforces a maximum of 2 requests per second per platform, which is the documented rate limit for most ATS public APIs. The code was clean, the tests were green, and the first production deploy blew through the cap within 90 seconds.<\/p>\n<p>The problem was not in the limiter configuration. It was in where the limiter lived. Each Inngest worker process \u2014 and I run six in parallel \u2014 called <code>getLimiter(&quot;greenhouse&quot;)<\/code> and got back its own Bottleneck instance with its own internal counter. Worker A tracked its own 500ms spacing. Worker B tracked its own. So did C, D, E, and F. Individually, each was perfectly rate-limited at 2 req\/s. Collectively, they were hitting Greenhouse at 12 req\/s, which is well above the threshold where ATS platforms start returning HTTP 429 or, worse, silently IP-banning the source.<\/p>\n<h2>Why Every Test Passed (and Why That Was the Real Bug)<\/h2>\n<p>The test suite had full coverage of the rate limiter. It verified that consecutive calls were spaced at least 500ms apart. It verified that different ATS sources got different limiter instances. It verified cleanup and reconnection. Every test passed. Every test was also completely useless for catching this bug, because every test ran inside a single Node.js process.<\/p>\n<h3>The Sequential Test Trap<\/h3>\n<p>Here is the test that gave me false confidence:<\/p>\n<pre><code>it(\"enforces the minTime rate limit between calls\", async () => {\n  const limiter = getLimiter(\"test-rate\");\n  const timestamps: number[] = [];\n  const recordTimestamp = () => {\n    timestamps.push(Date.now());\n    return Promise.resolve();\n  };\n\n  \/\/ Schedule 3 calls \u2014 they should be spaced at least ~500ms apart\n  await limiter.schedule(recordTimestamp);\n  await limiter.schedule(recordTimestamp);\n  await limiter.schedule(recordTimestamp);\n\n  expect(timestamps).toHaveLength(3);\n  const gap1 = timestamps[1] - timestamps[0];\n  const gap2 = timestamps[2] - timestamps[1];\n  expect(gap1).toBeGreaterThanOrEqual(450); \/\/ ~500ms - 50ms tolerance\n  expect(gap2).toBeGreaterThanOrEqual(450);\n});<\/code><\/pre>\n<p>This test is correct for what it tests: a single limiter instance in a single process enforces 500ms spacing between sequential calls. But the production bug was not about sequential calls to one limiter. It was about six separate limiter instances in six separate processes all hitting the same external API concurrently. No unit test can reproduce that, because unit tests do not spawn six Node.js processes and point them at the same rate-limited endpoint.<\/p>\n<h3>The Mock That Hid the Problem<\/h3>\n<p>The integration tests for the ATS adapters mocked the rate limiter entirely:<\/p>\n<pre><code>vi.mock(\"@\/lib\/jobs\/poller\/rate-limiter\", () => ({\n  getLimiter: () => ({\n    schedule: (fn: () => Promise&lt;unknown&gt;) =&gt; fn(),\n  }),\n}));<\/code><\/pre>\n<p>This mock executes the scheduled function immediately with no delay \u2014 which is correct for testing the adapter logic (Zod validation, normalisation, error handling) but means the integration tests never exercised the rate limiter at all. The rate limiter was tested in isolation, the adapters were tested with the rate limiter mocked out, and I never tested the two together across multiple processes. That gap is where the bug lived.<\/p>\n<figure class=\"vm-figure\"><img decoding=\"async\" src=\"https:\/\/vectormatch.dev\/blog\/wp-content\/uploads\/2026\/08\/photo-1717501218456-c4789b65fc21.jpg\" alt=\"Sequential test execution vs parallel production execution showing why tests missed the rate limit bug\" loading=\"lazy\"><figcaption>Sequential tests verify one limiter in one process. Production runs six limiters in six processes. The gap is where the bug lived.<\/figcaption><\/figure>\n<h2>The Wrong Mental Model: Process-Local Is Not Global<\/h2>\n<p>The root cause was not a coding error. It was a mental model error. My implicit assumption was that &quot;rate limited&quot; means &quot;the API will not receive more than 2 req\/s.&quot; What the code actually guaranteed was &quot;no single process will send more than 2 req\/s.&quot; Those are only equivalent when there is exactly one process.<\/p>\n<h3>The Assumption Stack<\/h3>\n<p>Here is the chain of assumptions that made the bug invisible:<\/p>\n<ul>\n<li><strong>Assumption 1:<\/strong> Bottleneck&#8217;s <code>minTime: 500<\/code> enforces 2 req\/s. <em>True within a single instance.<\/em><\/li>\n<li><strong>Assumption 2:<\/strong> <code>getLimiter(&quot;greenhouse&quot;)<\/code> returns the same limiter for all callers. <em>True within a single process. False across processes.<\/em><\/li>\n<li><strong>Assumption 3:<\/strong> Inngest worker functions share memory. <em>False. Each worker is a separate Node.js process with its own module cache.<\/em><\/li>\n<li><strong>Assumption 4:<\/strong> The test environment matches production topology. <em>False. Tests run in one process; production runs six.<\/em><\/li>\n<\/ul>\n<p>Each assumption is reasonable in isolation. The bug emerges from the composition: assumption 2 is true in tests (one process), which reinforces assumption 4, which hides the fact that assumption 3 is false in production. This is the classic shape of a distributed systems bug \u2014 the code is correct for the model I held, and the model was wrong.<\/p>\n<blockquote>\n<p>The rate limiter code was correct. The tests were correct. My mental model of what &quot;rate limited across six processes&quot; means was wrong. The bug was in the gap between the model and the reality of distributed execution.<\/p>\n<\/blockquote>\n<h3>Why Inngest Made This Hard to See<\/h3>\n<p><a href=\"https:\/\/www.inngest.com\/\" target=\"_blank\" rel=\"noopener\">Inngest<\/a> abstracts away the concurrency model. You write a function, register it, and Inngest handles fan-out, retries, and parallelism. In development, Inngest runs functions in a single process \u2014 so the rate limiter works perfectly. In production, Inngest&#8217;s worker pool scales horizontally, and each worker is a separate process. The platform does not advertise this prominently, and the local dev experience gives you a mental model of &quot;one process, one limiter&quot; that is silently false in production.<\/p>\n<p>This is not a criticism of Inngest specifically. Any job queue that scales workers horizontally (BullMQ, SQS + Lambda, Celery with multiple workers) has the same property. The rate limiter bug is a <em>distributed systems<\/em> bug, not an <em>Inngest<\/em> bug. But Inngest&#8217;s excellent local DX makes the single-process mental model particularly easy to accidentally hold.<\/p>\n<h2>The Fix: Move the Counter to Redis<\/h2>\n<p>The fix was conceptually simple: move the rate limiter&#8217;s state out of each process&#8217;s memory and into a shared store that all workers can see. I used Redis, via Bottleneck&#8217;s built-in <code>ioredis<\/code> datastore. This required zero changes to the calling code \u2014 <code>limiter.schedule(() =&gt; fetch(url))<\/code> works identically whether the limiter is in-process or Redis-backed. The only change was in how the Bottleneck instance is constructed.<\/p>\n<h3>Before: In-Process (Buggy)<\/h3>\n<pre><code>const limiters: Record&lt;string, Bottleneck&gt; = {};\n\nexport function getLimiter(atsSource: string): Bottleneck {\n  if (!limiters[atsSource]) {\n    limiters[atsSource] = new Bottleneck({\n      minTime: 500,       \/\/ 500ms between requests = 2 req\/s max\n      maxConcurrent: 1,   \/\/ no parallel requests\n    });\n  }\n  return limiters[atsSource];\n}<\/code><\/pre>\n<p>Each process gets its own <code>limiters<\/code> object. Six processes = six independent counters. The 2 req\/s cap is per-process, not global.<\/p>\n<h3>After: Redis-Backed (Correct)<\/h3>\n<pre><code>let bottleneckConnection: Bottleneck.IORedisConnection | null = null;\n\nfunction initRedis(): void {\n  const redisUrl = process.env.REDIS_URL;\n  if (!redisUrl) return; \/\/ in-process mode for dev\/CI\n\n  bottleneckConnection = new Bottleneck.IORedisConnection({\n    clientOptions: redisUrl,\n    Redis: IORedis,\n  });\n}\n\nexport function getLimiter(atsSource: string): Bottleneck {\n  if (!limiters[atsSource]) {\n    initRedis();\n\n    if (bottleneckConnection) {\n      \/\/ Distributed mode: Redis enforces the cap GLOBALLY.\n      limiters[atsSource] = new Bottleneck({\n        id: `ats:${atsSource}`,\n        datastore: \"ioredis\",\n        connection: bottleneckConnection,\n        minTime: 500,\n        maxConcurrent: 1,\n        clearBatcherBeforeStop: true,\n      });\n    } else {\n      \/\/ In-process fallback (dev, CI)\n      limiters[atsSource] = new Bottleneck({\n        minTime: 500,\n        maxConcurrent: 1,\n      });\n    }\n  }\n  return limiters[atsSource];\n}<\/code><\/pre>\n<p>The key addition is <code>datastore: &quot;ioredis&quot;<\/code> and the shared <code>bottleneckConnection<\/code>. Bottleneck uses Redis to store its internal state (the queue, the last-execution timestamp, the concurrency counter). When worker A schedules a request, it increments the Redis counter. Worker B sees that increment and waits. The cap is now global \u2014 2 req\/s across all six workers combined, not 2 req\/s per worker.<\/p>\n<p>The <code>id: &quot;ats:${atsSource}&quot;<\/code> field namespaces each ATS platform&#8217;s state in Redis, so Greenhouse and Lever still have independent rate limits. They just share the Redis connection.<\/p>\n<figure class=\"vm-figure\"><img decoding=\"async\" src=\"https:\/\/vectormatch.dev\/blog\/wp-content\/uploads\/2026\/08\/photo-1667372335936-3dc4ff716017.jpg\" alt=\"Redis-backed distributed rate limiter architecture showing all workers sharing a single counter via Redis\" loading=\"lazy\"><figcaption>The fix: all six workers share rate limiter state through Redis, enforcing the 2 req\/s cap globally.<\/figcaption><\/figure>\n<h3>The Fail-Closed Design<\/h3>\n<p>One critical design decision: if Redis is configured but unreachable, the limiter does <em>not<\/em> fall back to in-process mode silently. It throws. The caller catches the error and marks the ATS source as degraded. This is intentional. The failure mode of &quot;no rate limiting&quot; is an IP ban from the ATS platform, which is catastrophic and hard to recover from. The failure mode of &quot;stalled ingestion&quot; is self-healing \u2014 when Redis comes back, the poller resumes. I always choose the failure mode that self-heals over the one that makes things worse.<\/p>\n<pre><code>\/\/ If Redis is configured but unreachable, Bottleneck.schedule() throws.\n\/\/ The caller catches this and returns a \"network\" error, which the\n\/\/ circuit breaker treats as a degraded source.\n\/\/\n\/\/ This is the correct failure mode: it's safer to stall ingestion\n\/\/ than to risk uncoordinated requests that could trigger an ATS IP ban.<\/code><\/pre>\n<h3>Dev\/CI Behaviour<\/h3>\n<p>When <code>REDIS_URL<\/code> is unset (local dev, CI), the limiter falls back to in-process mode. This is correct: dev and CI run a single process, so the in-process cap is equivalent to the global cap. The fallback is logged with a warning so it is visible in CI output:<\/p>\n<pre><code>if (process.env.REDIS_URL) {\n  console.warn(\n    `[rate-limiter] REDIS_URL is set but Redis connection failed \u2014 ` +\n    `using in-process limiter for \"${atsSource}\". ` +\n    `Rate cap (2 req\/s) is NOT enforced across multiple workers.`\n  );\n}<\/code><\/pre>\n<h2>The Generalisable Lesson: Shared Constraints Must Live Outside the Process<\/h2>\n<p>This bug is not specific to rate limiters, Inngest, or Bottleneck. It is a general pattern that applies to any shared-resource constraint in a distributed system. If the constraint is a rate limit, a connection pool, a semaphore, or a quota \u2014 and the workload is handled by multiple processes \u2014 the constraint state must live in a shared store, not in process memory.<\/p>\n<h3>The Pattern<\/h3>\n<ul>\n<li><strong>Rate limiting:<\/strong> Use Redis, <a href=\"https:\/\/redis.io\/docs\/manual\/patterns\/rate-limiter\/\" target=\"_blank\" rel=\"noopener\">a token bucket in a shared store<\/a>, or an API gateway. In-process limiters are only safe for single-process deployments.<\/li>\n<li><strong>Connection pools:<\/strong> If six processes each open 10 database connections, you have 60 connections, not 10. Use a connection pooler like <a href=\"https:\/\/www.pgbouncer.org\/\" target=\"_blank\" rel=\"noopener\">PgBouncer<\/a> or a managed proxy.<\/li>\n<li><strong>Concurrency limits:<\/strong> A &quot;max 3 concurrent jobs&quot; constraint enforced in-process allows 18 concurrent jobs across six workers. Use a distributed lock or a shared counter.<\/li>\n<li><strong>Quota tracking:<\/strong> &quot;100 API calls per hour&quot; tracked per-process means 600 calls per hour across six processes. Track in Redis or a database.<\/li>\n<\/ul>\n<h3>The Testing Lesson<\/h3>\n<p>Sequential tests cannot catch concurrency bugs. If your production topology involves multiple processes hitting a shared resource, you need either:<\/p>\n<ol>\n<li><strong>Integration tests that spawn multiple processes<\/strong> and point them at the same resource (complex but realistic).<\/li>\n<li><strong>Chaos-style tests<\/strong> that simulate the failure mode (e.g., fire 12 concurrent requests and assert the external API receives at most 2 per second).<\/li>\n<li><strong>Load tests in a staging environment<\/strong> that matches production topology.<\/li>\n<\/ol>\n<p>The minimum viable fix for my case was adding a test that spawns two Node.js child processes, each calling <code>getLimiter(&quot;test&quot;)<\/code>, and asserts that the combined request rate does not exceed 2 req\/s. That test would have caught the bug. It would also have required a running Redis instance in CI, which is a trade-off worth making for any system where the rate limit exists to prevent IP bans.<\/p>\n<h2>What I Changed After This Bug<\/h2>\n<p>Beyond the Redis fix itself, I made three structural changes to prevent this class of bug from recurring. None of them are novel \u2014 they are standard distributed systems hygiene \u2014 but they were not in place when the rate limiter shipped, and their absence is what made the bug possible.<\/p>\n<h3>1. Document the Execution Topology Next to the Code<\/h3>\n<p>The rate limiter file now starts with a comment block that explicitly states the production topology and why it matters:<\/p>\n<pre><code>\/\/ When REDIS_URL is set, limiters use Bottleneck's Redis datastore so the\n\/\/ 2 req\/s cap is enforced GLOBALLY across all worker processes. This is\n\/\/ critical for multi-worker Inngest deployments \u2014 without it, each process\n\/\/ gets its own in-process limiter and the aggregate rate can exceed 2 req\/s,\n\/\/ risking IP bans from ATS platforms.<\/code><\/pre>\n<p>This comment exists because the next developer who reads this file \u2014 or me, three months from now \u2014 might not remember that Inngest runs six workers. The code should not require external knowledge to understand its constraints.<\/p>\n<h3>2. Fail-Closed on Infrastructure Dependencies<\/h3>\n<p>If Redis is configured but unreachable, the system stalls rather than running without rate limits. This is the opposite of the common &quot;fail open&quot; pattern where a missing dependency causes the safety check to be skipped. For rate limiting, failing open means &quot;no rate limit,&quot; which means IP bans. Failing closed means &quot;no ingestion,&quot; which means a delayed pipeline. I always choose the failure mode that does not cause irreversible damage.<\/p>\n<h3>3. Environment Parity for Shared State<\/h3>\n<p>Staging now runs with Redis configured, matching production. The previous staging setup ran without Redis (using in-process mode), which meant staging could not reproduce any bug that depended on the Redis\/no-Redis boundary. Environment parity is not just about matching Node versions \u2014 it is about matching the presence and behaviour of every shared-state dependency.<\/p>\n<section class=\"vm-faq\" aria-labelledby=\"vm-faq-heading\">\n<h2 id=\"vm-faq-heading\">Frequently Asked Questions<\/h2>\n<div class=\"vm-faq-list\">\n<details class=\"vm-faq-item\" id=\"vm-faq-item-1\">\n<summary class=\"vm-faq-question\" style=\"padding-left: 1rem; cursor: pointer;\">Why did the in-process rate limiter pass all tests but fail in production?<\/summary>\n<div class=\"vm-faq-answer\">The tests ran sequentially inside a single Node.js process, where the in-process Bottleneck instance correctly enforced 2 req\/s. In production, Inngest ran six parallel worker processes, each with its own Bottleneck instance and counter. Each process was individually rate-limited at 2 req\/s, but the aggregate was 12 req\/s. No single-process test can reproduce a multi-process concurrency bug because it only manifests when multiple independent processes hit the same external resource simultaneously.<\/div>\n<\/details>\n<details class=\"vm-faq-item\" id=\"vm-faq-item-2\">\n<summary class=\"vm-faq-question\" style=\"padding-left: 1rem; cursor: pointer;\">What happens if Redis goes down \u2014 does the rate limiter fail open or closed?<\/summary>\n<div class=\"vm-faq-answer\">It fails closed. If Redis is configured but unreachable, Bottleneck.schedule() throws after ioredis exhausts retries. The caller catches this and marks the ATS source as degraded, halting ingestion. This is intentional: the failure mode of &#8216;no rate limiting&#8217; is an IP ban, which is catastrophic and hard to recover from. The failure mode of &#8216;stalled ingestion&#8217; is self-healing \u2014 when Redis returns, the poller resumes. I always choose the failure mode that self-heals.<\/div>\n<\/details>\n<\/div>\n<p>  <script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why did the in-process rate limiter pass all tests but fail in production?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"The tests ran sequentially inside a single Node.js process, where the in-process Bottleneck instance correctly enforced 2 req\/s. In production, Inngest ran six parallel worker processes, each with its own Bottleneck instance and counter. Each process was individually rate-limited at 2 req\/s, but the aggregate was 12 req\/s. No single-process test can reproduce a multi-process concurrency bug because it only manifests when multiple independent processes hit the same external resource simultaneously.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What happens if Redis goes down \u2014 does the rate limiter fail open or closed?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"It fails closed. If Redis is configured but unreachable, Bottleneck.schedule() throws after ioredis exhausts retries. The caller catches this and marks the ATS source as degraded, halting ingestion. This is intentional: the failure mode of 'no rate limiting' is an IP ban, which is catastrophic and hard to recover from. The failure mode of 'stalled ingestion' is self-healing \u2014 when Redis returns, the poller resumes. I always choose the failure mode that self-heals.\"\n      }\n    }\n  ]\n}\n<\/script><\/p>\n<\/section>\n<p>The rate limiter bug was not a coding error. It was a mental model error \u2014 my assumption that &#8216;rate limited&#8217; means &#8216;globally rate limited&#8217; when the code only enforced &#8216;per-process rate limited.&#8217; Six parallel Inngest workers, each with their own Bottleneck instance, hit the ATS API at six times the intended rate. The tests passed because they ran sequentially in a single process, which is the one topology where the bug does not manifest.<\/p>\n<p>The fix was moving the limiter state to Redis via Bottleneck&#8217;s ioredis datastore, making the 2 req\/s cap global across all workers. The calling code did not change. The failure mode was designed to fail closed \u2014 if Redis is down, ingestion stalls rather than risking an IP ban. This is the correct trade-off for any constraint where the failure mode of &#8216;no limit&#8217; is worse than the failure mode of &#8216;no throughput.&#8217;<\/p>\n<p>The generalisable lesson: any shared-resource constraint \u2014 rate limit, connection pool, concurrency cap, quota \u2014 must live outside the process when the workload is distributed. And sequential tests cannot catch bugs that only manifest under parallel execution across multiple processes. If your production topology involves more than one process hitting a shared resource, your tests need to reflect that.<\/p>\n<div class=\"vm-cta\"><a href=\"https:\/\/vectormatch.dev\" class=\"btn-brand\">I build VectorMatch \u2014 it matches developers to roles by semantic fit \u2192<\/a><\/div>\n<div class=\"vm-author-box\">\n<p><strong><a href=\"https:\/\/vectormatch.dev\/blog\/dusan-knezevic\/\" style=\"color:inherit;text-decoration:none;\">Du\u0161an Kne\u017eevi\u0107<\/a><\/strong><\/p>\n<p>I build VectorMatch, an AI-driven job-matching platform for developers. I write about the production bugs, fixes, and lessons I encounter while shipping it. <a href=\"https:\/\/vectormatch.dev\/blog\/dusan-knezevic\/\">More about me &rarr;<\/a><\/p>\n<\/div>\n<p><script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"Article\",\n  \"author\": {\n    \"@id\": \"https:\/\/vectormatch.dev\/blog\/dusan-knezevic\/#person\"\n  }\n}\n<\/script><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Six parallel Inngest workers each held their own rate limit counter, blowing through a 2 req\/s cap. Tests passed because they were sequential. Redis fixed it.<\/p>\n","protected":false},"author":1,"featured_media":160,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[39],"tags":[40,44,42,41],"class_list":["post-158","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-engineering-notes","tag-inngest","tag-production","tag-rate-limiting","tag-redis"],"_links":{"self":[{"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/posts\/158","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/comments?post=158"}],"version-history":[{"count":3,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/posts\/158\/revisions"}],"predecessor-version":[{"id":164,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/posts\/158\/revisions\/164"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/media\/160"}],"wp:attachment":[{"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/media?parent=158"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/categories?post=158"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vectormatch.dev\/blog\/wp-json\/wp\/v2\/tags?post=158"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}