DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our PWA plugin never emitted a service worker, and Turbopack is the reason

CogniPrep's next.config.mjs used to export its config through a PWA wrapper, @ducanh2912/next-pwa, configured with two options:

  • cacheOnFrontEndNav
  • aggressiveFrontEndNavCaching

Both read as "client side navigation is faster now". Neither did anything, and had not for as long as anyone could check.

next-pwa and its forks work by injecting a webpack plugin, which is the thing that runs Workbox and writes sw.js into your output. next build on this project compiles with Turbopack, so that plugin was never instantiated. No error. No warning. The build succeeded every time, and the config kept sitting at the top of the file looking like a feature.

Next.js 16 is where most people will meet this, because Turbopack is the default builder there rather than a flag you opt into. If your project carried a webpack-plugin-shaped dependency across that upgrade, it is worth checking whether it is still doing its job, because the failure mode is silence.

How to prove it is dead rather than assuming

The check that settled it took a minute:

  1. public/sw.js does not exist in the working tree.
  2. No service worker exists in the build output.
  3. git log has never contained one.
  4. On the deployed site, https://cogniprep.app/sw.js returns 404.
  5. In the browser, await navigator.serviceWorker.getRegistrations() returns [].

Point 5 is the one I would start with on any site you have inherited. Paste it into the console of the production page. If the array is empty and your config says you have a PWA, your config is a comment.

What replaced the wrapper in our repo is a comment explaining the hole, which is the only honest thing to leave behind:

// REMOVED: @ducanh2912/next-pwa.
//
// It was configured with `cacheOnFrontEndNav` and `aggressiveFrontEndNavCaching`,
// which read as an attempt to make client-side navigation faster. It never did
// anything. The plugin works by injecting a webpack plugin, and `next build`
// compiles this project with Turbopack, so no service worker was ever emitted.
//
// Dead configuration that looks like a performance feature is worse than none,
// because it stops anyone from asking why navigation is slow.
Enter fullscreen mode Exit fullscreen mode

Dead config is worse than no config for exactly that reason. As long as the file says "aggressive navigation caching", nobody profiles navigation. The line was an answer to a question that had never been asked properly.

The half we do ship

What survived is the manifest, which in the App Router is a TypeScript file rather than a static asset:

import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'CogniPrep - Arctic Shores Practice',
    short_name: 'CogniPrep',
    description: 'Practice all 14 Arctic Shores psychometric games and AI mock video interviews.',
    start_url: '/',
    display: 'standalone',
    background_color: '#000000',
    theme_color: '#000000',
    orientation: 'portrait',
    icons: [
      { src: '/logo-light.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
      { src: '/logo-light.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

Next serves that at /manifest.webmanifest and injects the <link rel="manifest"> tag into every page for you. There is no <link> anywhere in our layout; the file's existence is the wiring.

That gets you the metadata half of a progressive web app: the name and icon a phone uses when someone saves the page to their home screen, the theme colour the browser paints its chrome with, and the standalone display mode that hides the URL bar once the app is installed. It gets you none of the offline half, because offline is entirely a service worker concern.

Being clear about which half you have matters, because a manifest is the part that makes a site look installable in an audit tool while the behaviour people actually associate with a PWA, working on the train, is missing.

Two things in that manifest are still wrong

Writing this post is what made me read the object properly, which is the usual outcome of explaining your own config to strangers.

Both icons are the same file. One entry is purpose: 'any', the other is purpose: 'maskable', and they point at the same 512 square PNG. Maskable icons are not a flag you set on an existing asset. The platform crops them to whatever shape it likes, a circle, a squircle, a rounded square, and only the middle 80% is guaranteed to survive. A maskable entry needs to be a separate export with the mark shrunk inside that safe zone. Declaring it on the standard icon means Android is free to shave the edges off our logo.

There is no 192 pixel icon. The conventional set is 192 and 512. Shipping only one size leaves the smaller slots to be produced by downscaling, which is exactly where a detailed mark goes muddy.

Neither is a crisis, because nobody can install the thing as an offline app today anyway. Both are on the list for when a Turbopack-compatible service worker, Serwist is the usual successor, is actually worth doing.

See it

  • cogniprep.app/manifest.webmanifest: the live output of that TypeScript function. Note the two identical icon entries.
  • cogniprep.app/sw.js: 404, which is the whole post in one request.
  • Open cogniprep.app, then DevTools, Application, Service Workers. The list is empty. Compare with Application, Manifest, which is fully populated. That gap is the shape of a half-built PWA and it is very common.
  • View source and search for rel="manifest". It is there, and it is not in our source code.

The lesson I took from it is narrower than "audit your dependencies". It is that a build plugin which no longer runs produces the same successful build as one that does, so migrating bundlers needs an output check per plugin, not a green build.

Top comments (0)