Skip to main content

Zero Views, Sixty-Two Applications: How a Strict Zod Schema Silently Broke Our Telemetry

How a subtle mismatch between PostgreSQL integer types, TypeScript interfaces, and runtime Zod validation caused a 3-day silent telemetry blackout on OnlyFrontendJobs without throwing a single user-visible error.

September 6, 20267 min readBy Deepak Sharma
ShareXLinkedIn
Zero Views, Sixty-Two Applications: How a Strict Zod Schema Silently Broke Our Telemetry

I opened the admin dashboard on OnlyFrontendJobs this morning and stared at the metrics table in disbelief.

Here is what the dashboard showed:

Job Title                                        Status     Type     Views   Apps   Posted
[Software Engineer 3 - Frontend] (Bidgely)       Published  Remote     0      62    2d ago
[Senior Frontend Engineer] (ClickUp)             Published  Remote     0      31    1d ago
[Senior Software Engineer in AI] (Five9)         Published  Hybrid     0      20    2d ago
[Full Stack Software Engineer] (ClickHouse)      Published  Remote     0      18    1d ago
[Frontend Software Engineer] (Corporater)        Published  Onsite     0       8    Just now

Think about those numbers for a second.

Sixty-two candidates had applied for the Bidgely role. Thirty-one candidates had applied for ClickUp. Twenty candidates applied to Five9.

And every single job reported 0 views.

How does an application get sixty-two candidates when literally zero humans visited the page? Did Google index a phantom link? Was a rogue scraper submitting bot traffic directly to our apply endpoint? Or had we introduced a bug that broke mathematics?

The answer wasn't a bot attack or an ATS ghost. It was a classic, insidious trap where TypeScript, PostgreSQL, and runtime Zod validation teamed up to silently swallow every view event for three straight days without triggering a single user-visible error.

Here is the postmortem of how it happened, why our tests missed it, and what we changed to fix it.


The Architecture: Two Independent Metrics

On OnlyFrontendJobs, every job posting tracks two key metrics:

  1. Views (views_count): Incremented when a user lands on /jobs/[slug].
  2. Applications (applications_count): Incremented when a user clicks through to the external ATS or submits an application.

These two metrics use completely different storage and update paths:

  • When someone clicks the "Apply" button, an event hits /api/jobs/[id]/apply, which executes an atomic SQL update directly on the jobs table:

    UPDATE jobs SET applicant_count = COALESCE(applicant_count, 0) + 1 WHERE id = $1;
    

    That worked flawlessly. Candidates clicked "Apply", the count incremented, and the numbers went up: 11, 20, 31, 62.

  • But the view count uses an asynchronous, lightweight event logger. When the job detail page mounts in the candidate's browser, a client component (JobEngagement) fires a background request to /api/views:

    fetch('/api/views', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ type: 'job', contentId: jobId }),
    })
    

    On the server, /api/views logs this visit into a dedicated content_views table:

    INSERT INTO content_views (type, content_id, view_count)
    VALUES ($1, $2, 1)
    ON CONFLICT (type, content_id)
    DO UPDATE SET view_count = content_views.view_count + 1;
    

In the admin dashboard, the query joins these two tables together:

SELECT 
  j.id, 
  j.title, 
  COALESCE(cv.view_count, 0) as views_count, 
  COALESCE(j.applicant_count, 0) as applications_count
FROM jobs j
LEFT JOIN content_views cv ON cv.content_id = j.id::text AND cv.type = 'job';

Because the join is a LEFT JOIN, if a job has no entry in content_views, COALESCE defaults views_count to 0.

And since September 3, not a single row had been added to content_views.


The Culprit: A Well-Intentioned Security Hardening

Three days earlier, we did a security and performance sweep. One of the items on the audit was /api/views.

Historically, /api/views didn't validate its payload strictly. Anyone could send arbitrary strings. So we added strict Zod schema validation:

// src/lib/content-views.ts
import { z } from 'zod';

export const viewPayloadSchema = z.object({
  type: z.enum(['job', 'blog']),
  contentId: z
    .string()
    .trim()
    .min(1, 'contentId cannot be empty')
    .max(128, 'contentId cannot exceed 128 characters')
    .regex(
      /^[a-zA-Z0-9_-]+$/,
      'contentId must contain only alphanumeric characters, dashes, or underscores',
    ),
});

It looked clean, defensive, and thoroughly tested.

We even wrote a unit test for it:

test('parseViewPayload accepts job and blog ids', () => {
  assert.equal(parseViewPayload({ type: 'job', contentId: '42' }).success, true);
  assert.equal(parseViewPayload({ type: 'blog', contentId: 'my-post_1' }).success, true);
});

The tests passed. CI went green. The pull request was merged.

And immediately, all view tracking died.


Why TypeScript Didn't Warn Us

Look closely at the test we wrote:

parseViewPayload({ type: 'job', contentId: '42' }); // string!

We tested with a string '42'. In blog posts, contentId is the article slug, which is naturally a string (e.g. 'lighthouse-score-perfect'). So blog views kept working fine.

Now look at how the job detail page renders the engagement component:

// src/app/jobs/[slug]/page.tsx
<JobEngagement jobId={job.id} />

And check our TypeScript type definition for Job:

// src/app/jobs/[slug]/types.ts
export interface Job {
  id: string; // ๐Ÿ‘ˆ Look at this type annotation!
  title: string;
  slug: string;
  // ...
}

TypeScript was completely satisfied. JobEngagement expected { jobId: string }, and Job.id was typed as string. The compiler emitted zero warnings.

Except at runtime, job.id was NOT a string.

In PostgreSQL, the jobs.id column is an integer primary key:

id integer NOT NULL DEFAULT nextval('jobs_id_seq'::regclass)

When the Node.js pg driver executes SELECT j.id ..., it deserializes PostgreSQL int4 columns into JavaScript numbers (859, 855, 845), not strings!

Because data.ts executed raw SQL and returned result.rows[0] as Job, TypeScript accepted the cast on faith.


The Chain Reaction of Failure

Here is the exact chain of events that unfolded on every candidate visit:

  1. A candidate loaded /jobs/software-engineer-3-frontend-bidgely-ezoz0.
  2. The server fetched the job from Postgres. job.id was the number 845.
  3. <JobEngagement jobId={845} /> mounted on the client.
  4. useViewTracker serialized the payload:
    JSON.stringify({ type: 'job', contentId: 845 })
    
    Notice that contentId is a raw JSON number: 845.
  5. The request reached POST /api/views.
  6. Zod ran viewPayloadSchema.safeParse(body).
  7. Zod checked contentId: z.string(). It received a number.
  8. Zod failed validation and returned:
    {
      "error": "Expected string, received number",
      "path": ["contentId"]
    }
    
  9. The API handler returned 400 Bad Request.
  10. On the frontend, useViewTracker was written with defensive telemetry hygiene:
    fetch('/api/views', { ... })
      .catch(() => {
        // Silently fail โ€” don't break the candidate's reading experience
      });
    
    The promise rejected or parsed an error response, the catch block swallowed it, and the page rendered smoothly without a single console error or red screen.

Everything looked completely healthy. The UI was fast. Candidates read job descriptions, clicked "Apply", and submitted resumes.

The only thing that broke was our telemetry, which recorded zero views across thousands of visitor sessions.


The Fix

The fix required two simple adjustments: server-side flexibility and client-side normalization.

1. Make the Zod Schema Accept Strings or Numbers

Instead of blindly assuming the caller will always pass a string, we accept either a string or a number, transform it to a clean string, and pipe it through our format constraints:

// src/lib/content-views.ts
export const viewPayloadSchema = z.object({
  type: z.enum(['job', 'blog']),
  contentId: z
    .union([z.string(), z.number()])
    .transform((v) => String(v).trim())
    .pipe(
      z
        .string()
        .min(1, 'contentId cannot be empty')
        .max(128, 'contentId cannot exceed 128 characters')
        .regex(
          /^[a-zA-Z0-9_-]+$/,
          'contentId must contain only alphanumeric characters, dashes, or underscores',
        ),
    ),
});

Now, whether the caller sends "859" or 859, Zod coerces it to "859" and validates that it is a safe identifier.

2. Defensively Cast on the Client

Even with the server accepting numbers, client telemetry shouldn't send raw types when a string contract is expected. We updated useViewTracker.ts and JobEngagement.tsx to explicitly cast IDs:

// src/hooks/useViewTracker.ts
const normalizedId = String(contentId || '').trim();

// Inside fetch:
body: JSON.stringify({ type, contentId: normalizedId }),

3. Test With Real Runtime Payloads

We updated our automated test suite to verify that numeric IDs succeed:

// tests/api-views-validation.test.ts
test('parseViewPayload accepts job and blog ids', () => {
  assert.equal(parseViewPayload({ type: 'job', contentId: '42' }).success, true);
  assert.equal(parseViewPayload({ type: 'blog', contentId: 'my-post_1' }).success, true);

  // Runtime Postgres jobs.id is a number โ€” must accept number and coerce to string
  const numJob = parseViewPayload({ type: 'job', contentId: 859 });
  assert.equal(numJob.success, true);
  if (numJob.success) {
    assert.equal(numJob.data.contentId, '859');
  }
});

We ran the test suite:

pnpm exec tsx --test tests/api-views-validation.test.ts
# โœ” parseViewPayload accepts job and blog ids (1.3ms)
# 6 passed, 0 failed

Then we fired a test hit at our local development endpoint:

curl -X POST http://localhost:3000/api/views \
  -H "Content-Type: application/json" \
  -d '{"type":"job","contentId":859}'

Response:

{"type":"job","contentId":"859","views":1}

The database immediately recorded the view. In the admin dashboard, the Views column came right back to life.


Lessons for Fullstack Teams

  1. TypeScript types are not runtime guarantees. Writing interface Job { id: string } does not magically convert a Postgres int4 integer into a string. If your database driver emits numbers, you are dealing with numbers at runtime.
  2. Strict validation can be too strict. When validating identifiers at API boundaries, use z.union([z.string(), z.number()]).transform(...) or z.coerce.string() if numeric IDs are common in your data model.
  3. Silent failure protects users, but blinds operators. Wrapping telemetry in catch(() => {}) is the right UX choice (analytics failures should never crash a user's screen). But without an observability counter or Sentry sample for 4xx validation errors, a bad schema can silence your analytics pipeline indefinitely.
  4. Test your types against what your database actually emits. When testing API contracts, don't just test with clean mocks that match your TypeScript interfaces. Test with the raw JSON shapes that your real components send.

Now, when candidates view the next role on the job board, our view counts will actually reflect the humans behind the applications.

Found this useful? Share it.

ShareXLinkedIn

Ready to land your next frontend role?

Browse curated frontend jobs from top companies โ€” updated daily.

Browse Frontend Jobs โ†’